diff --git a/frontend/.babelrc.js b/frontend/.babelrc.js index 0147e92e..88dc51d1 100644 --- a/frontend/.babelrc.js +++ b/frontend/.babelrc.js @@ -14,7 +14,7 @@ module.exports = { '@babel/preset-react', { pragma: 'h', - pragmaFrag: 'div', + pragmaFrag: 'Fragment', }, ], ], diff --git a/frontend/app/common/api.ts b/frontend/app/common/api.ts index 5b4e32d3..114f606b 100644 --- a/frontend/app/common/api.ts +++ b/frontend/app/common/api.ts @@ -275,6 +275,28 @@ export const uploadImage = (image: File): Promise => { })); }; +/** + * 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, diff --git a/frontend/app/common/static_store.ts b/frontend/app/common/static_store.ts index 5c464722..2d2e8f7d 100644 --- a/frontend/app/common/static_store.ts +++ b/frontend/app/common/static_store.ts @@ -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, }; diff --git a/frontend/app/common/types.ts b/frontend/app/common/types.ts index 125486b4..a7b70bfe 100644 --- a/frontend/app/common/types.ts +++ b/frontend/app/common/types.ts @@ -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 { diff --git a/frontend/app/components/auth-panel/auth-panel.tsx b/frontend/app/components/auth-panel/auth-panel.tsx index 3e625443..88cf2126 100644 --- a/frontend/app/components/auth-panel/auth-panel.tsx +++ b/frontend/app/components/auth-panel/auth-panel.tsx @@ -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'; diff --git a/frontend/app/components/button/_kind/_link/button_kind_link.scss b/frontend/app/components/button/_kind/_link/button_kind_link.scss index 46a5505a..829729f8 100644 --- a/frontend/app/components/button/_kind/_link/button_kind_link.scss +++ b/frontend/app/components/button/_kind/_link/button_kind_link.scss @@ -6,4 +6,9 @@ &:hover { color: #06c5c5; } + + &:disabled, + &:hover:disabled { + color: #0aa; + } } diff --git a/frontend/app/components/button/_kind/_primary/button_kind_primary.scss b/frontend/app/components/button/_kind/_primary/button_kind_primary.scss index 0fbef096..87c539f5 100644 --- a/frontend/app/components/button/_kind/_primary/button_kind_primary.scss +++ b/frontend/app/components/button/_kind/_primary/button_kind_primary.scss @@ -6,7 +6,7 @@ background: #06c5c5; } - &:hover&:disabled { + &:hover:disabled { background: #259c9a; } } diff --git a/frontend/app/components/button/_theme/_dark/button_theme_dark.scss b/frontend/app/components/button/_theme/_dark/button_theme_dark.scss index a3431cc5..063a1283 100644 --- a/frontend/app/components/button/_theme/_dark/button_theme_dark.scss +++ b/frontend/app/components/button/_theme/_dark/button_theme_dark.scss @@ -2,3 +2,10 @@ background: #22201c; color: #ddd; } + +.button_theme_dark.button_kind_link { + &:disabled, + &:hover:disabled { + color: #fff; + } +} diff --git a/frontend/app/components/button/button.scss b/frontend/app/components/button/button.scss index 2080eca3..eb5f7275 100644 --- a/frontend/app/components/button/button.scss +++ b/frontend/app/components/button/button.scss @@ -15,7 +15,7 @@ } &:disabled { - opacity: 0.4; + opacity: 0.6; cursor: default; } } diff --git a/frontend/app/components/button/button.tsx b/frontend/app/components/button/button.tsx index c08e992a..25883a81 100644 --- a/frontend/app/components/button/button.tsx +++ b/frontend/app/components/button/button.tsx @@ -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 { +interface Props extends Omit { kind?: 'primary' | 'secondary' | 'link'; size?: 'middle' | 'large'; theme?: Theme; diff --git a/frontend/app/components/comment-form/__button/comment-form__button.scss b/frontend/app/components/comment-form/__button/comment-form__button.scss index eea1c1e5..a1e37e84 100644 --- a/frontend/app/components/comment-form/__button/comment-form__button.scss +++ b/frontend/app/components/comment-form/__button/comment-form__button.scss @@ -1,4 +1,8 @@ .comment-form__button { margin: 8px 8px 0 0; align-self: flex-start; + + & + .comment-form__button { + margin-right: 20px; + } } diff --git a/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.scss b/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.scss new file mode 100644 index 00000000..cb800ae1 --- /dev/null +++ b/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.scss @@ -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; +} diff --git a/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.test.tsx b/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.test.tsx new file mode 100644 index 00000000..028c7390 --- /dev/null +++ b/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.test.tsx @@ -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(' { + const createWrapper = (store: ReturnType = mockStore(initialStore)) => + mount( + + + + ); + + 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('', () => { + const createWrapper = (store: ReturnType = mockStore(initialStore)) => + mount( + + + + ); + 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'); + }); +}); diff --git a/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx b/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx new file mode 100644 index 00000000..97476190 --- /dev/null +++ b/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx @@ -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 +) => [ +
Subscribe to replies
, + , +]; + +const renderTokenPart = ( + loading: boolean, + token: string, + handleChangeToken: (e: Event) => void, + setEmailStep: () => void +) => [ + , + , +]; + +export const SubscribeByEmailForm: FunctionComponent = () => { + const theme = useTheme(); + const dispatch = useDispatch(); + const subscribed = useSelector(({ user }) => + user === null ? false : Boolean(user.email_subscription) + ); + const emailAddressRef = useRef(); + const previousStep = useRef(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(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 ( +
+ {text} + +
+ ); + } + + 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 ( +
+ You have been unsubscribed by email to updates + +
+ ); + } + + const buttonLabel = step === Step.Email ? 'Submit' : 'Subscribe'; + + return ( +
+ {step === Step.Email && renderEmailPart(loading, emailAddress, handleChangeEmail, emailAddressRef)} + {step === Step.Token && renderTokenPart(loading, token, handleChangeToken, setEmailStep)} + {error !== null && ( +
+ {error} +
+ )} + +
+ ); +}; + +export const SubscribeByEmail: FunctionComponent = () => { + const theme = useTheme(); + const user = useSelector(({ user }) => user); + const isAnonymous = isUserAnonymous(user); + const buttonTitle = isAnonymous ? 'Available only for registered users' : 'Subscribe by Email'; + + return ( + + + + ); +}; diff --git a/frontend/app/components/comment-form/__subscribe-by-email/index.ts b/frontend/app/components/comment-form/__subscribe-by-email/index.ts new file mode 100644 index 00000000..2c8d39bc --- /dev/null +++ b/frontend/app/components/comment-form/__subscribe-by-email/index.ts @@ -0,0 +1 @@ +export { SubscribeByEmail, SubscribeByEmailForm } from './comment-form__subscribe-by-email'; diff --git a/frontend/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.scss b/frontend/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.scss new file mode 100644 index 00000000..3a18a280 --- /dev/null +++ b/frontend/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.scss @@ -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; + } +} diff --git a/frontend/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.test.tsx b/frontend/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.test.tsx new file mode 100644 index 00000000..5f0aff40 --- /dev/null +++ b/frontend/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.test.tsx @@ -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('', () => { + let wrapper: ReturnType; + + beforeAll(() => { + wrapper = shallow(); + }); + + 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')); + }); +}); diff --git a/frontend/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.tsx b/frontend/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.tsx new file mode 100644 index 00000000..bd89560a --- /dev/null +++ b/frontend/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.tsx @@ -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 ( + + {items.map(([href, label]) => ( + + + {label} + + + ))} + + ); +}; diff --git a/frontend/app/components/comment-form/__subscribe-by-rss/index.ts b/frontend/app/components/comment-form/__subscribe-by-rss/index.ts new file mode 100644 index 00000000..4fa43b5f --- /dev/null +++ b/frontend/app/components/comment-form/__subscribe-by-rss/index.ts @@ -0,0 +1 @@ +export { SubscribeByRSS, createSubscribeUrl } from './comment-form__subscribe-by-rss'; diff --git a/frontend/app/components/comment-form/comment-form.scss b/frontend/app/components/comment-form/comment-form.scss index 3e635bf9..7cb5fedc 100644 --- a/frontend/app/components/comment-form/comment-form.scss +++ b/frontend/app/components/comment-form/comment-form.scss @@ -6,3 +6,11 @@ border-width: 6px 12px 12px 12px; border-radius: 2px; } + +.comment-form__dropdown_rss { + text-align: left; + + .dropdown__content { + width: 8em; + } +} diff --git a/frontend/app/components/comment-form/comment-form.test.tsx b/frontend/app/components/comment-form/comment-form.test.tsx index e144c5d7..191e84ee 100644 --- a/frontend/app/components/comment-form/comment-form.test.tsx +++ b/frontend/app/components/comment-form/comment-form.test.tsx @@ -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 = { + mode: 'main', + theme: 'light', + onSubmit: () => Promise.resolve(), + getPreview: () => Promise.resolve(''), + user: null, +}; describe('', () => { - it('shoud render without control panel, preview button, and rss links in "simple view" mode', () => { - const element = shallow(); + it('should render without control panel, preview button, and rss links in "simple view" mode', () => { + const props = { ...DEFAULT_PROPS, simpleView: true }; + const wrapper = shallow(); - 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(); + + 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(); + + expect(wrapper.exists(SubscribeByEmail)).toEqual(false); }); }); diff --git a/frontend/app/components/comment-form/comment-form.tsx b/frontend/app/components/comment-form/comment-form.tsx index ed7b4d8a..5d1defaf 100644 --- a/frontend/app/components/comment-form/comment-form.tsx +++ b/frontend/app/components/comment-form/comment-form.tsx @@ -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 { } 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 { Styling with{' '} Markdown - {' '} - is supported + + {' is supported'} - Subscribe to the{' '} - - Thread - - {', '} - - Site - {' '} - or  - - Replies - {' '} - by RSS + {'Subscribe by '} + + {StaticStore.config.email_notifications && ( + + {' or '} + + + )} )} diff --git a/frontend/app/components/comment-form/index.ts b/frontend/app/components/comment-form/index.ts index f44db006..1e4c199d 100644 --- a/frontend/app/components/comment-form/index.ts +++ b/frontend/app/components/comment-form/index.ts @@ -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'; diff --git a/frontend/app/components/comment/comment.tsx b/frontend/app/components/comment/comment.tsx index bfd55012..3c508f3d 100644 --- a/frontend/app/components/comment/comment.tsx +++ b/frontend/app/components/comment/comment.tsx @@ -725,6 +725,7 @@ export class Comment extends Component { {isReplying && props.view === 'main' && ( { {isEditing && props.view === 'main' && ( & JSX.HTMLAttributes & { separator?: boolean }) { - const { children, separator = false } = props; - - return
{children}
; -} +export const DropdownItem: FunctionComponent = ({ children, separator = false }) => ( +
{children}
+); diff --git a/frontend/app/components/dropdown/__item/index.ts b/frontend/app/components/dropdown/__item/index.ts index 3fdf4de3..c877463e 100644 --- a/frontend/app/components/dropdown/__item/index.ts +++ b/frontend/app/components/dropdown/__item/index.ts @@ -1,3 +1 @@ -import Dropdown__Item from './dropdown__item'; - -export default Dropdown__Item; +export { DropdownItem } from './dropdown__item'; diff --git a/frontend/app/components/dropdown/dropdown.tsx b/frontend/app/components/dropdown/dropdown.tsx index 24b5fa72..ddd4e2b5 100644 --- a/frontend/app/components/dropdown/dropdown.tsx +++ b/frontend/app/components/dropdown/dropdown.tsx @@ -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 { +export class Dropdown extends Component { rootNode = createRef(); storedDocumentHeight: string | null = null; storedDocumentHeightSet: boolean = false; @@ -115,7 +117,7 @@ export default class Dropdown extends Component { // TODO: use ref const dc = this.rootNode.current.querySelector('.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 { 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 (
- -
- {heading &&
{heading}
} -
{children}
-
+ {isActive && ( +
+ {heading &&
{heading}
} +
{children}
+
+ )}
); } diff --git a/frontend/app/components/dropdown/index.ts b/frontend/app/components/dropdown/index.ts index c039e235..072393fd 100644 --- a/frontend/app/components/dropdown/index.ts +++ b/frontend/app/components/dropdown/index.ts @@ -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'; diff --git a/frontend/app/components/input/input.tsx b/frontend/app/components/input/input.tsx index 500ba050..6210bd31 100644 --- a/frontend/app/components/input/input.tsx +++ b/frontend/app/components/input/input.tsx @@ -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 { kind?: 'primary' | 'secondary'; theme?: Theme; mods?: Mods; diff --git a/frontend/app/components/root/root.tsx b/frontend/app/components/root/root.tsx index 42da7705..5e6cc68e 100644 --- a/frontend/app/components/root/root.tsx +++ b/frontend/app/components/root/root.tsx @@ -246,7 +246,7 @@ export class Root extends Component { 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} diff --git a/frontend/app/hooks/useTheme.ts b/frontend/app/hooks/useTheme.ts new file mode 100644 index 00000000..750afa4d --- /dev/null +++ b/frontend/app/hooks/useTheme.ts @@ -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(({ theme }) => theme); + + return theme; +} diff --git a/frontend/app/remark.tsx b/frontend/app/remark.tsx index 7645c82b..2adb6ba3 100644 --- a/frontend/app/remark.tsx +++ b/frontend/app/remark.tsx @@ -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 { 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 { StaticStore.config = await api.getConfig(); if (params.page === 'user-info') { - render( + return render(
@@ -70,12 +69,12 @@ async function init(): Promise {
, node ); - } else { - render( - - - , - node - ); } + + render( + + + , + node + ); } diff --git a/frontend/app/store/user/actions.ts b/frontend/app/store/user/actions.ts index e96f4f57..1033e21d 100644 --- a/frontend/app/store/user/actions.ts +++ b/frontend/app/store/user/actions.ts @@ -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 => d }); return state; }; + +export const setUserSubscribed = (isSubscribed: boolean) => ({ + type: USER_SUBSCRIPTION_SET, + payload: isSubscribed, +}); diff --git a/frontend/app/store/user/reducers.ts b/frontend/app/store/user/reducers.ts index 75543efe..cc98e7bb 100644 --- a/frontend/app/store/user/reducers.ts +++ b/frontend/app/store/user/reducers.ts @@ -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; } diff --git a/frontend/app/store/user/types.ts b/frontend/app/store/user/types.ts index 96fb6b2b..537b0cad 100644 --- a/frontend/app/store/user/types.ts +++ b/frontend/app/store/user/types.ts @@ -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; diff --git a/frontend/app/testUtils/index.ts b/frontend/app/testUtils/index.ts index 1177b387..fa789b93 100644 --- a/frontend/app/testUtils/index.ts +++ b/frontend/app/testUtils/index.ts @@ -23,5 +23,6 @@ beforeEach(() => { version: 'jest-test', simple_view: false, anon_vote: false, + email_notifications: false, }; }); diff --git a/frontend/app/testUtils/mockApi.ts b/frontend/app/testUtils/mockApi.ts new file mode 100644 index 00000000..0e3bb73d --- /dev/null +++ b/frontend/app/testUtils/mockApi.ts @@ -0,0 +1 @@ +jest.mock('@app/common/api'); diff --git a/frontend/app/testUtils/mocks/user.ts b/frontend/app/testUtils/mocks/user.ts new file mode 100644 index 00000000..1331f190 --- /dev/null +++ b/frontend/app/testUtils/mocks/user.ts @@ -0,0 +1,19 @@ +import { User } from '@app/common/types'; + +const user: Readonly = { + 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, + id: 'anonymous_1', +}; + +export { user, anonymousUser }; diff --git a/frontend/app/utils/errorUtils.ts b/frontend/app/utils/errorUtils.ts index 9c7efa9a..3fec6972 100644 --- a/frontend/app/utils/errorUtils.ts +++ b/frontend/app/utils/errorUtils.ts @@ -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; } diff --git a/frontend/app/utils/isUserAnonymous.test.ts b/frontend/app/utils/isUserAnonymous.test.ts new file mode 100644 index 00000000..eddba120 --- /dev/null +++ b/frontend/app/utils/isUserAnonymous.test.ts @@ -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); + }); +}); diff --git a/frontend/app/utils/isUserAnonymous.ts b/frontend/app/utils/isUserAnonymous.ts index e64aa9e6..c32d6e7b 100644 --- a/frontend/app/utils/isUserAnonymous.ts +++ b/frontend/app/utils/isUserAnonymous.ts @@ -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_'; }