From 93fd445bd47a3e9e6119a3aaf3fd90bcb4b3e855 Mon Sep 17 00:00:00 2001 From: Pavel Mineev Date: Wed, 11 Mar 2020 01:35:37 +0300 Subject: [PATCH] On-demand auth * auth block was moved to comment form * if comments on page read only auth form shows in old place * comment value in form will be saved between refreshes (it is side effect form saving comment value between unauth and auth states) --- frontend/app/common/api.ts | 4 +- frontend/app/common/constants.ts | 3 + .../auth-panel__anonymous-login-form.scss | 18 -- .../__anonymous-login-form/index.ts | 3 - .../__column/auth-panel__column.scss | 6 +- .../auth-panel/__email-login-form/index.ts | 3 - .../components/auth-panel/auth-panel.test.tsx | 199 ++++-------- .../app/components/auth-panel/auth-panel.tsx | 299 ++++-------------- frontend/app/components/auth-panel/index.ts | 2 +- .../auth__anonymous-login-form.scss | 18 ++ .../auth__anonymous-login-form.tsx} | 10 +- .../auth/__anonymous-login-form/index.ts | 3 + .../auth__email-login-form.scss} | 18 +- .../auth__email-login-form.test.tsx} | 4 +- .../auth__email-login-form.tsx} | 22 +- .../auth/__email-login-form/index.ts | 3 + frontend/app/components/auth/auth.module.pcss | 4 + frontend/app/components/auth/auth.test.tsx | 70 ++++ frontend/app/components/auth/auth.tsx | 198 ++++++++++++ frontend/app/components/auth/index.ts | 1 + .../__actions/comment-form__actions.scss | 3 +- .../__button/comment-form__button.scss | 4 +- .../__field/comment-form__field.pcss | 1 + .../comment-form__markdown-toolbar.scss | 7 +- .../__markdown/comment-form__markdown.scss | 1 + .../comment-form/__rss/comment-form__rss.scss | 1 - .../components/comment-form/comment-form.scss | 1 - .../comment-form/comment-form.test.tsx | 1 + .../components/comment-form/comment-form.tsx | 118 ++++--- frontend/app/components/comment/comment.tsx | 70 ++-- frontend/app/components/root/root.tsx | 90 ++---- frontend/app/components/thread/thread.scss | 1 - frontend/app/components/thread/thread.tsx | 5 +- frontend/app/store/comments/actions.ts | 42 ++- frontend/app/store/comments/getters.ts | 4 +- frontend/app/store/comments/reducers.ts | 44 ++- frontend/app/store/comments/types.ts | 28 +- frontend/app/store/comments/utils.ts | 13 +- frontend/app/store/reducers.ts | 2 +- frontend/app/store/user/actions.ts | 3 +- 40 files changed, 718 insertions(+), 609 deletions(-) delete mode 100644 frontend/app/components/auth-panel/__anonymous-login-form/auth-panel__anonymous-login-form.scss delete mode 100644 frontend/app/components/auth-panel/__anonymous-login-form/index.ts delete mode 100644 frontend/app/components/auth-panel/__email-login-form/index.ts create mode 100644 frontend/app/components/auth/__anonymous-login-form/auth__anonymous-login-form.scss rename frontend/app/components/{auth-panel/__anonymous-login-form/auth-panel__anonymous-login-form.tsx => auth/__anonymous-login-form/auth__anonymous-login-form.tsx} (90%) create mode 100644 frontend/app/components/auth/__anonymous-login-form/index.ts rename frontend/app/components/{auth-panel/__email-login-form/auth-panel__email-login-form.scss => auth/__email-login-form/auth__email-login-form.scss} (64%) rename frontend/app/components/{auth-panel/__email-login-form/auth-panel__email-login-form.test.tsx => auth/__email-login-form/auth__email-login-form.test.tsx} (96%) rename frontend/app/components/{auth-panel/__email-login-form/auth-panel__email-login-form.tsx => auth/__email-login-form/auth__email-login-form.tsx} (91%) create mode 100644 frontend/app/components/auth/__email-login-form/index.ts create mode 100644 frontend/app/components/auth/auth.module.pcss create mode 100644 frontend/app/components/auth/auth.test.tsx create mode 100644 frontend/app/components/auth/auth.tsx create mode 100644 frontend/app/components/auth/index.ts diff --git a/frontend/app/common/api.ts b/frontend/app/common/api.ts index bd81e389..0bdb0de0 100644 --- a/frontend/app/common/api.ts +++ b/frontend/app/common/api.ts @@ -69,8 +69,8 @@ export const logOut = (): Promise => export const getConfig = (): Promise => fetcher.get(`/config`); -export const getPostComments = (sort: Sorting): Promise => - fetcher.get({ +export const getPostComments = (sort: Sorting) => + fetcher.get({ url: `/find?site=${siteId}&url=${url}&sort=${sort}&format=tree`, withCredentials: true, }); diff --git a/frontend/app/common/constants.ts b/frontend/app/common/constants.ts index 6f97da88..7ad0ca67 100644 --- a/frontend/app/common/constants.ts +++ b/frontend/app/common/constants.ts @@ -21,6 +21,9 @@ export const PROVIDER_NAMES: { [P in AuthProvider['name']]: string } = { /** locastorage key for collapsed comments */ export const LS_COLLAPSE_KEY = '__remarkCollapsed'; +/** locastorage key for comment form value */ +export const LS_SAVED_COMMENT_VALUE = '__remark_comment_value'; + /** locastorage key for hidden users */ export const LS_HIDDEN_USERS_KEY = '__remarkHiddenUsers'; diff --git a/frontend/app/components/auth-panel/__anonymous-login-form/auth-panel__anonymous-login-form.scss b/frontend/app/components/auth-panel/__anonymous-login-form/auth-panel__anonymous-login-form.scss deleted file mode 100644 index d0d26b41..00000000 --- a/frontend/app/components/auth-panel/__anonymous-login-form/auth-panel__anonymous-login-form.scss +++ /dev/null @@ -1,18 +0,0 @@ -.auth-panel-anonymous-login-form { - padding: 0.5em 0.7em; - display: flex; - flex-direction: row; - flex-wrap: nowrap; -} - -.auth-panel-anonymous-login-form__input { - width: 9em; -} - -.auth-panel-anonymous-login-form__remember-me { - display: none; -} - -.auth-panel-anonymous-login-form__submit { - margin-left: 0.5em; -} diff --git a/frontend/app/components/auth-panel/__anonymous-login-form/index.ts b/frontend/app/components/auth-panel/__anonymous-login-form/index.ts deleted file mode 100644 index a6df1772..00000000 --- a/frontend/app/components/auth-panel/__anonymous-login-form/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { AnonymousLoginForm } from './auth-panel__anonymous-login-form'; - -import './auth-panel__anonymous-login-form.scss'; diff --git a/frontend/app/components/auth-panel/__column/auth-panel__column.scss b/frontend/app/components/auth-panel/__column/auth-panel__column.scss index 8706f23b..0a93f81c 100644 --- a/frontend/app/components/auth-panel/__column/auth-panel__column.scss +++ b/frontend/app/components/auth-panel/__column/auth-panel__column.scss @@ -1,9 +1,5 @@ .auth-panel__column { - &:nth-child(1) { - font-weight: 700; - } - - &:nth-child(2) { + &:last-child { margin-left: 8px; text-align: right; } diff --git a/frontend/app/components/auth-panel/__email-login-form/index.ts b/frontend/app/components/auth-panel/__email-login-form/index.ts deleted file mode 100644 index ca1697f0..00000000 --- a/frontend/app/components/auth-panel/__email-login-form/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { EmailLoginForm, EmailLoginFormConnected } from './auth-panel__email-login-form'; - -import './auth-panel__email-login-form.scss'; diff --git a/frontend/app/components/auth-panel/auth-panel.test.tsx b/frontend/app/components/auth-panel/auth-panel.test.tsx index a3597c51..1a2951b3 100644 --- a/frontend/app/components/auth-panel/auth-panel.test.tsx +++ b/frontend/app/components/auth-panel/auth-panel.test.tsx @@ -1,16 +1,18 @@ /** @jsx createElement */ import { createElement } from 'preact'; import { mount } from 'enzyme'; - -import { Button } from '@app/components/button'; -import { User, PostInfo } from '@app/common/types'; - -import { Props, AuthPanelWithIntl as AuthPanel } from './auth-panel'; +import createMockStore from 'redux-mock-store'; +import { Middleware } from 'redux'; +import { Provider } from 'react-redux'; import { IntlProvider } from 'react-intl'; -import enMessages from '../../locales/en.json'; -const DefaultProps: Partial = { - sort: '-score', +import enMessages from '@app/locales/en.json'; + +import AuthPanel, { Props } from './auth-panel'; +import { Button } from '../button'; +import { StaticStore } from '@app/common/static_store'; + +const DefaultProps = { providers: ['google', 'github'], provider: { name: null }, postInfo: { @@ -19,112 +21,36 @@ const DefaultProps: Partial = { count: 3, }, hiddenUsers: {}, -}; +} as Props; + +const initialStore = { + user: null, + theme: 'light', + comments: { + sort: '-score', + }, + provider: { name: 'google' }, +} as const; + +const mockStore = createMockStore([] as Middleware[]); describe('', () => { - describe('For not authorized user', () => { - it('should render login form with google and github provider', () => { - const element = mount( - - - - ); - - const authPanelColumn = element.find('.auth-panel__column'); - - expect(authPanelColumn.length).toEqual(2); - - const authForm = authPanelColumn.first(); - - expect(authForm.text()).toEqual(expect.stringContaining('Login:')); - - const providerLinks = authForm.find(Button); - - expect(providerLinks.at(0).text()).toEqual('Google'); - expect(providerLinks.at(1).text()).toEqual('GitHub'); - }); - - describe('sorting', () => { - it('should place selected provider first', () => { - const element = mount( - - - - ); - - const providerLinks = element - .find('.auth-panel__column') - .first() - .find(Button); - - expect(providerLinks.at(0).text()).toEqual('GitHub'); - expect(providerLinks.at(1).text()).toEqual('Google'); - expect(providerLinks.at(2).text()).toEqual('Yandex'); - }); - - it('should do nothing if provider not found', () => { - const element = mount( - - - - ); - - const providerLinks = element - .find('.auth-panel__column') - .first() - .find(Button); - - expect(providerLinks.at(0).text()).toEqual('Google'); - expect(providerLinks.at(1).text()).toEqual('GitHub'); - expect(providerLinks.at(2).text()).toEqual('Yandex'); - }); - }); - - it('should render login form with google and github provider for read-only post', () => { - const element = mount( - - - - ); - - const authPanelColumn = element.find('.auth-panel__column'); - - expect(authPanelColumn.length).toEqual(2); - - const authForm = authPanelColumn.first(); - - expect(authForm.text()).toEqual(expect.stringContaining('Login: Google or GitHub')); - - const providerLinks = authForm.find(Button); - - expect(providerLinks.at(0).text()).toEqual('Google'); - expect(providerLinks.at(1).text()).toEqual('GitHub'); - }); + const createWrapper = (props: Props = DefaultProps, store: ReturnType = mockStore(initialStore)) => + mount( + + + + + + ); + describe('For not authorized : null', () => { it('should not render settings if there is no hidden users', () => { - const element = mount( - - - - ); + const element = createWrapper({ + ...DefaultProps, + user: null, + postInfo: { ...DefaultProps.postInfo, read_only: true }, + } as Props); const adminAction = element.find('.auth-panel__admin-action'); @@ -132,29 +58,43 @@ describe('', () => { }); it('should render settings if there is some hidden users', () => { - const element = mount( - - - - ); + const element = createWrapper({ + ...DefaultProps, + user: null, + postInfo: { ...DefaultProps.postInfo, read_only: true }, + hiddenUsers: { hidden_joe: {} as any }, + } as Props); const adminAction = element.find('.auth-panel__admin-action'); expect(adminAction.text()).toEqual('Show settings'); }); + + it('should render auth for read only post', () => { + StaticStore.config.auth_providers = ['google', 'github']; + + const element = createWrapper({ + ...DefaultProps, + user: null, + postInfo: { ...DefaultProps.postInfo, read_only: true }, + hiddenUsers: { hidden_joe: {} as any }, + } as Props); + + const firstCol = element.find('.auth-panel__column').first(); + const providerButtons = firstCol.find(Button); + + expect(firstCol.text()).toStartWith('Login:'); + expect(providerButtons.at(0).text()).toBe('Google'); + expect(providerButtons.at(1).text()).toBe('GitHub'); + }); }); + describe('For authorized user', () => { it('should render info about current user', () => { - const element = mount( - - - - ); + const element = createWrapper({ + ...DefaultProps, + user: { id: 'john', name: 'John' }, + } as Props); const authPanelColumn = element.find('.auth-panel__column'); @@ -167,11 +107,10 @@ describe('', () => { }); describe('For admin user', () => { it('should render admin action', () => { - const element = mount( - - {' '} - - ); + const element = createWrapper({ + ...DefaultProps, + user: { id: 'test', admin: true, name: 'John' }, + } as Props); const adminAction = element.find('.auth-panel__admin-action').first(); diff --git a/frontend/app/components/auth-panel/auth-panel.tsx b/frontend/app/components/auth-panel/auth-panel.tsx index 1c1029ff..03a71b3a 100644 --- a/frontend/app/components/auth-panel/auth-panel.tsx +++ b/frontend/app/components/auth-panel/auth-panel.tsx @@ -1,33 +1,28 @@ /** @jsx createElement */ -import { createElement, Component, createRef } from 'preact'; +import { createElement, Component, Fragment } from 'preact'; +import { useSelector } from 'react-redux'; +import { FormattedMessage, defineMessages, IntlShape, useIntl } from 'react-intl'; import b from 'bem-react-helper'; -import { PROVIDER_NAMES, IS_STORAGE_AVAILABLE, IS_THIRD_PARTY } from '@app/common/constants'; -import { requestDeletion } from '@app/utils/email'; -import { getHandleClickProps } from '@app/common/accessibility'; import { User, AuthProvider, Sorting, Theme, PostInfo } from '@app/common/types'; - -import debounce from '@app/utils/debounce'; +import { IS_STORAGE_AVAILABLE, IS_THIRD_PARTY } from '@app/common/constants'; +import { requestDeletion } from '@app/utils/email'; import postMessage from '@app/utils/postMessage'; +import { getHandleClickProps } from '@app/common/accessibility'; import { StoreState } from '@app/store'; import { ProviderState } from '@app/store/provider/reducers'; import { Dropdown, DropdownItem } from '@app/components/dropdown'; import { Button } from '@app/components/button'; -import { FormattedMessage, defineMessages, IntlShape, useIntl } from 'react-intl'; +import Auth from '@app/components/auth'; -import { AnonymousLoginForm } from './__anonymous-login-form'; -import { EmailLoginFormConnected } from './__email-login-form'; -import { EmailLoginFormRef } from './__email-login-form/auth-panel__email-login-form'; +import useTheme from '@app/hooks/useTheme'; +import { StaticStore } from '@app/common/static_store'; -interface PropsWithoutIntl { +export interface OwnProps { user: User | null; hiddenUsers: StoreState['hiddenUsers']; - sort: Sorting; isCommentsDisabled: boolean; - theme: Theme; postInfo: PostInfo; - providers: AuthProvider['name'][]; - provider: ProviderState; onSortChange(s: Sorting): Promise; onSignIn(p: AuthProvider): Promise; @@ -37,77 +32,31 @@ interface PropsWithoutIntl { onBlockedUsersHide(): void; } -export type Props = PropsWithoutIntl & { intl: IntlShape }; +export interface Props extends OwnProps { + intl: IntlShape; + theme: Theme; + providers: AuthProvider['name'][]; + provider: ProviderState; + sort: Sorting; +} interface State { isBlockedVisible: boolean; anonymousUsernameInputValue: string; - threshold: number; sortSelectFocused: boolean; } -const authPanelMessages = defineMessages({ - otherProvider: { - id: 'authPanel.other-provider', - defaultMessage: 'Other', - }, - anonymousProvider: { - id: 'authPanel.anonymous-provider', - defaultMessage: 'Anonymous', - }, - orProvider: { - id: 'authPanel.or-provider', - defaultMessage: 'or', - }, -}); - export class AuthPanel extends Component { - emailLoginRef = createRef(); + state = { + isBlockedVisible: false, + anonymousUsernameInputValue: 'anon', + sortSelectFocused: false, + }; - constructor(props: Props) { - super(props); - - this.state = { - isBlockedVisible: false, - anonymousUsernameInputValue: 'anon', - threshold: 3, - sortSelectFocused: false, - }; - - this.toggleBlockedVisibility = this.toggleBlockedVisibility.bind(this); - this.onSortChange = this.onSortChange.bind(this); - this.onSignIn = this.onSignIn.bind(this); - this.onEmailSignIn = this.onEmailSignIn.bind(this); - this.handleAnonymousLoginFormSubmut = this.handleAnonymousLoginFormSubmut.bind(this); - this.handleOAuthLogin = this.handleOAuthLogin.bind(this); - this.toggleUserInfoVisibility = this.toggleUserInfoVisibility.bind(this); - this.onEmailTitleClick = this.onEmailTitleClick.bind(this); - } - - componentWillMount() { - this.resizeHandler(); - window.addEventListener('resize', this.resizeHandler); - } - - componentWillUnmount() { - window.removeEventListener('resize', this.resizeHandler); - } - - singInMessageAndSortWidth = 255; - - resizeHandler = debounce(() => { - this.setState({ - threshold: Math.max(3, Math.round((window.innerWidth - this.singInMessageAndSortWidth) / 80)), - }); - }, 100); - - onEmailTitleClick() { - this.emailLoginRef.current && this.emailLoginRef.current.focus(); - } - - onSortChange(e: Event) { - this.props.onSortChange((e.target! as HTMLOptionElement).value as Sorting); - } + onSortChange = (e: Event) => { + const { value } = e.target as HTMLOptionElement; + this.props.onSortChange(value as Sorting); + }; onSortFocus = () => { this.setState({ sortSelectFocused: true }); @@ -115,56 +64,35 @@ export class AuthPanel extends Component { onSortBlur = (e: Event) => { this.setState({ sortSelectFocused: false }); - this.onSortChange(e); }; - toggleBlockedVisibility() { + toggleBlockedVisibility = () => { if (!this.state.isBlockedVisible) { if (this.props.onBlockedUsersShow) this.props.onBlockedUsersShow(); } else if (this.props.onBlockedUsersHide) this.props.onBlockedUsersHide(); this.setState({ isBlockedVisible: !this.state.isBlockedVisible }); - } + }; toggleCommentsAvailability = () => { this.props.onCommentsChangeReadOnlyMode(!this.props.isCommentsDisabled); }; - toggleUserInfoVisibility() { - const user = this.props.user; + toggleUserInfoVisibility = () => { + const { user } = this.props; + if (window.parent && user) { - const data = { isUserInfoShown: true, user }; - postMessage(data); + postMessage({ isUserInfoShown: true, user }); } - } - - /** wrapper function to handle both oauth and anonymous providers*/ - onSignIn(provider: AuthProvider) { - this.props.onSignIn(provider); - } - - onEmailSignIn(token: string) { - return this.props.onSignIn({ name: 'email', token }); - } - - async handleAnonymousLoginFormSubmut(username: string) { - this.onSignIn({ name: 'anonymous', username }); - } - - async handleOAuthLogin(e: MouseEvent | KeyboardEvent) { - const p = (e.target as HTMLButtonElement).dataset.provider! as AuthProvider['name']; - this.onSignIn({ name: p } as AuthProvider); - } - - renderAuthorized = () => { - const { user, onSignOut, theme } = this.props; - if (!user) return null; + }; + renderAuthorized = (user: User) => { + const { onSignOut, theme } = this.props; const isUserAnonymous = user && user.id.substr(0, 10) === 'anonymous_'; return ( -
+ {' '} @@ -188,124 +116,7 @@ export class AuthPanel extends Component { -
- ); - }; - - renderProvider = (provider: AuthProvider['name'], dropdown = false) => { - if (provider === 'anonymous') { - const anonymous = this.props.intl.formatMessage(authPanelMessages.anonymousProvider); - return ( - - - - - - ); - } - if (provider === 'email') { - return ( - - - - - - ); - } - - return ( - - ); - }; - - renderOther = (providers: AuthProvider['name'][]) => { - const other = this.props.intl.formatMessage(authPanelMessages.otherProvider); - return ( - - {providers.map(provider => ( - {this.renderProvider(provider, true)} - ))} - - ); - }; - - renderUnauthorized = () => { - const { user, providers = [] } = this.props; - const { threshold } = this.state; - if (user || !IS_STORAGE_AVAILABLE) return null; - - const sortedProviders = ((): typeof providers => { - if (!this.props.provider.name) return providers; - const lastProviderIndex = providers.indexOf(this.props.provider.name as typeof providers[0]); - if (lastProviderIndex < 1) return providers; - return [ - this.props.provider.name as typeof providers[0], - ...providers.slice(0, lastProviderIndex), - ...providers.slice(lastProviderIndex + 1), - ]; - })(); - - const isAboveThreshold = sortedProviders.length > threshold; - const or = this.props.intl.formatMessage(authPanelMessages.orProvider); - return ( -
- {' '} - {!isAboveThreshold && - sortedProviders.map((provider, i) => { - const comma = i === 0 ? '' : i === sortedProviders.length - 1 ? ` ${or} ` : ', '; - - return ( - - {comma} - {this.renderProvider(provider)} - - ); - })} - {isAboveThreshold && - sortedProviders.slice(0, threshold - 1).map((provider, i) => { - const comma = i === 0 ? '' : ', '; - - return ( - - {comma} - {this.renderProvider(provider)} - - ); - })} - {isAboveThreshold && ( - - {` ${or} `} - {this.renderOther(sortedProviders.slice(threshold - 1))} - - )} -
+ ); }; @@ -342,7 +153,7 @@ export class AuthPanel extends Component { { spellcheck={false} autocomplete="off" /> - {this.state.error &&
{this.state.error}
} + {this.state.error &&
{this.state.error}
} + ); + }; + + render({ intl }: Props, { threshold }: State) { + if (!IS_STORAGE_AVAILABLE) return null; + + const sortedProviders = ((providers): typeof providers => { + if (!this.props.provider.name) return providers; + const lastProviderIndex = providers.indexOf(this.props.provider.name as typeof providers[0]); + if (lastProviderIndex < 1) return providers; + return [ + this.props.provider.name as typeof providers[0], + ...providers.slice(0, lastProviderIndex), + ...providers.slice(lastProviderIndex + 1), + ]; + })(StaticStore.config.auth_providers); + + const isAboveThreshold = sortedProviders.length > threshold; + const or = intl.formatMessage(authPanelMessages.orProvider); + + return ( +
+ {' '} + {!isAboveThreshold && + sortedProviders.map((provider, i) => { + const comma = i === 0 ? '' : i === sortedProviders.length - 1 ? ` ${or} ` : ', '; + + return ( + + {comma} + {this.renderProvider(provider)} + + ); + })} + {isAboveThreshold && + sortedProviders.slice(0, threshold - 1).map((provider, i) => { + const comma = i === 0 ? '' : ', '; + + return ( + + {comma} + {this.renderProvider(provider)} + + ); + })} + {isAboveThreshold && ( + + {` ${or} `} + {this.renderOther(sortedProviders.slice(threshold - 1))} + + )} +
+ ); + } +} + +const authPanelMessages = defineMessages({ + otherProvider: { + id: 'authPanel.other-provider', + defaultMessage: 'Other', + }, + anonymousProvider: { + id: 'authPanel.anonymous-provider', + defaultMessage: 'Anonymous', + }, + orProvider: { + id: 'authPanel.or-provider', + defaultMessage: 'or', + }, +}); + +export default function() { + const dispatch = useDispatch(); + const provider = useSelector(store => store.provider); + const user = useSelector(store => store.user); + const theme = useTheme(); + const intl = useIntl(); + const handleSignin = useCallback((provider: AuthProvider) => dispatch(logIn(provider)), []); + + return ; +} diff --git a/frontend/app/components/auth/index.ts b/frontend/app/components/auth/index.ts new file mode 100644 index 00000000..1248e7f5 --- /dev/null +++ b/frontend/app/components/auth/index.ts @@ -0,0 +1 @@ +export { default } from './auth'; diff --git a/frontend/app/components/comment-form/__actions/comment-form__actions.scss b/frontend/app/components/comment-form/__actions/comment-form__actions.scss index fc464662..6685d069 100644 --- a/frontend/app/components/comment-form/__actions/comment-form__actions.scss +++ b/frontend/app/components/comment-form/__actions/comment-form__actions.scss @@ -1,7 +1,8 @@ .comment-form__actions { display: flex; align-items: center; - padding-top: 8px; + padding-top: 12px; flex-wrap: wrap; justify-content: space-between; + min-height: 30px; } 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 a1e37e84..f9920167 100644 --- a/frontend/app/components/comment-form/__button/comment-form__button.scss +++ b/frontend/app/components/comment-form/__button/comment-form__button.scss @@ -1,8 +1,8 @@ .comment-form__button { - margin: 8px 8px 0 0; + margin-right: 8px; align-self: flex-start; - & + .comment-form__button { + &:last-child { margin-right: 20px; } } diff --git a/frontend/app/components/comment-form/__field/comment-form__field.pcss b/frontend/app/components/comment-form/__field/comment-form__field.pcss index 255123db..417aa6d9 100644 --- a/frontend/app/components/comment-form/__field/comment-form__field.pcss +++ b/frontend/app/components/comment-form/__field/comment-form__field.pcss @@ -5,6 +5,7 @@ $lines: 4; $height: calc($fontSize * $lineHeight * $lines + $paddingVrt * 2); + display: block; box-sizing: border-box; width: 100%; height: $height; diff --git a/frontend/app/components/comment-form/__markdown-toolbar/comment-form__markdown-toolbar.scss b/frontend/app/components/comment-form/__markdown-toolbar/comment-form__markdown-toolbar.scss index 32a04772..25121358 100644 --- a/frontend/app/components/comment-form/__markdown-toolbar/comment-form__markdown-toolbar.scss +++ b/frontend/app/components/comment-form/__markdown-toolbar/comment-form__markdown-toolbar.scss @@ -15,9 +15,12 @@ background: none; border: 0; color: var(--color37); - display: block; + display: flex; float: left; - padding: 4px 5px; + height: 24px; + width: 24px; + justify-content: center; + align-items: center; &:hover { color: var(--color9); diff --git a/frontend/app/components/comment-form/__markdown/comment-form__markdown.scss b/frontend/app/components/comment-form/__markdown/comment-form__markdown.scss index 8288a500..83dd806b 100644 --- a/frontend/app/components/comment-form/__markdown/comment-form__markdown.scss +++ b/frontend/app/components/comment-form/__markdown/comment-form__markdown.scss @@ -1,3 +1,4 @@ .comment-form__markdown { margin-bottom: 5px; + font-size: 12px; } diff --git a/frontend/app/components/comment-form/__rss/comment-form__rss.scss b/frontend/app/components/comment-form/__rss/comment-form__rss.scss index 4df1610e..d0ee50ca 100644 --- a/frontend/app/components/comment-form/__rss/comment-form__rss.scss +++ b/frontend/app/components/comment-form/__rss/comment-form__rss.scss @@ -1,5 +1,4 @@ .comment-form__rss { - margin-top: 8px; font-size: 12px; line-height: 1; } diff --git a/frontend/app/components/comment-form/comment-form.scss b/frontend/app/components/comment-form/comment-form.scss index 7cb5fedc..b475a13e 100644 --- a/frontend/app/components/comment-form/comment-form.scss +++ b/frontend/app/components/comment-form/comment-form.scss @@ -1,7 +1,6 @@ .comment-form { position: relative; display: block; - font-size: 0; border-style: solid; border-width: 6px 12px 12px 12px; border-radius: 2px; diff --git a/frontend/app/components/comment-form/comment-form.test.tsx b/frontend/app/components/comment-form/comment-form.test.tsx index 91dd09b1..7848f85c 100644 --- a/frontend/app/components/comment-form/comment-form.test.tsx +++ b/frontend/app/components/comment-form/comment-form.test.tsx @@ -14,6 +14,7 @@ const DEFAULT_PROPS: Readonly> = { onSubmit: () => Promise.resolve(), getPreview: () => Promise.resolve(''), user: null, + id: '1', }; const intl = { diff --git a/frontend/app/components/comment-form/comment-form.tsx b/frontend/app/components/comment-form/comment-form.tsx index abd2ce2b..4c095cbd 100644 --- a/frontend/app/components/comment-form/comment-form.tsx +++ b/frontend/app/components/comment-form/comment-form.tsx @@ -10,6 +10,9 @@ 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 Auth from '@app/components/auth'; +import { getItem, setItem } from '@app/common/local-storage'; +import { LS_SAVED_COMMENT_VALUE } from '@app/common/constants'; import { SubscribeByEmail } from './__subscribe-by-email'; import { SubscribeByRSS } from './__subscribe-by-rss'; @@ -21,6 +24,7 @@ import { TextExpander } from './text-expander'; let textareaId = 0; export interface Props { + id: string; user: User | null; errorMessage?: string; value?: string; @@ -95,6 +99,15 @@ export class CommentForm extends Component { super(props); textareaId = textareaId + 1; this.textareaId = `textarea_${textareaId}`; + + const savedCommentsJSON = getItem(LS_SAVED_COMMENT_VALUE); + let savedValue = ''; + try { + if (typeof savedCommentsJSON === 'string') { + savedValue = JSON.parse(savedCommentsJSON)[this.props.id] || ''; + } + } catch (e) {} + this.state = { preview: null, isErrorShown: false, @@ -102,7 +115,7 @@ export class CommentForm extends Component { errorLock: false, isDisabled: false, maxLength: StaticStore.config.max_comment_size, - text: props.value || '', + text: props.value || savedValue, buttonText: null, }; @@ -147,10 +160,16 @@ export class CommentForm extends Component { } onInput(e: Event) { + const { value } = e.target as HTMLInputElement; + + try { + setItem(LS_SAVED_COMMENT_VALUE, JSON.stringify({ [this.props.id]: value })); + } catch (e) {} + if (this.state.errorLock) { this.setState({ preview: null, - text: (e.target as HTMLInputElement).value, + text: value, }); return; } @@ -158,7 +177,7 @@ export class CommentForm extends Component { isErrorShown: false, errorMessage: null, preview: null, - text: (e.target as HTMLInputElement).value, + text: value, }); } @@ -380,6 +399,22 @@ export class CommentForm extends Component { this.setState({ errorLock: false, isDisabled: false, buttonText: null }); } + renderMarkdownTip = () => ( +
+ ( + + {title} + + ), + }} + /> +
+ ); + render(props: Props, { isDisabled, isErrorShown, errorMessage, preview, maxLength, text, buttonText }: State) { const charactersLeft = maxLength - text.length; errorMessage = props.errorMessage || errorMessage; @@ -444,48 +479,45 @@ export class CommentForm extends Component { ))}
-
- {!props.simpleView && ( - - )} - -
- - {!props.simpleView && props.mode === 'main' && ( -
-
- ( - - {title} - - ), - }} - /> + {this.props.user ? ( + +
+ {!props.simpleView && ( + + )} +
- {' '} - - {StaticStore.config.email_notifications && ( - - {' '} - - + + {!props.simpleView && props.mode === 'main' && ( +
+ {this.renderMarkdownTip()} + {' '} + + {StaticStore.config.email_notifications && ( + + {' '} + + + )} +
)} -
+ + ) : ( + + + {this.renderMarkdownTip()} + )}
diff --git a/frontend/app/components/comment/comment.tsx b/frontend/app/components/comment/comment.tsx index 9f978e4a..36865c9a 100644 --- a/frontend/app/components/comment/comment.tsx +++ b/frontend/app/components/comment/comment.tsx @@ -141,45 +141,10 @@ export interface State { } class Comment extends Component { - votingPromise: Promise; + votingPromise: Promise = Promise.resolve(); /** comment text node. Used in comment text copying */ textNode = createRef(); - constructor(props: Props) { - super(props); - - this.state = { - renderDummy: typeof props.inView === 'boolean' ? !props.inView : false, - isCopied: false, - editDeadline: null, - voteErrorMessage: null, - scoreDelta: 0, - cachedScore: props.data.score, - initial: true, - ...this.updateState(props), - }; - - this.votingPromise = Promise.resolve(); - - this.toggleEditing = this.toggleEditing.bind(this); - this.toggleReplying = this.toggleReplying.bind(this); - this.blockUser = debounce(this.blockUser, 100).bind(this); - } - - // getHandleClickProps = (handler?: (e: KeyboardEvent | MouseEvent) => void) => { - // if (this.state.initial) return null; - // if (this.props.inView === false) return null; - // return getHandleClickProps(handler); - // }; - - componentWillReceiveProps(nextProps: Props) { - this.setState(this.updateState(nextProps)); - } - - componentDidMount() { - this.setState({ initial: false }); - } - updateState = (props: Props) => { const newState: Partial = { scoreDelta: props.data.vote, @@ -206,6 +171,31 @@ class Comment extends Component { return newState; }; + state = { + renderDummy: typeof this.props.inView === 'boolean' ? !this.props.inView : false, + isCopied: false, + editDeadline: null, + voteErrorMessage: null, + scoreDelta: 0, + cachedScore: this.props.data.score, + initial: true, + ...this.updateState(this.props), + }; + + // getHandleClickProps = (handler?: (e: KeyboardEvent | MouseEvent) => void) => { + // if (this.state.initial) return null; + // if (this.props.inView === false) return null; + // return getHandleClickProps(handler); + // }; + + componentWillReceiveProps(nextProps: Props) { + this.setState(this.updateState(nextProps)); + } + + componentDidMount() { + this.setState({ initial: false }); + } + toggleReplying = () => { const { editMode } = this.props; if (editMode === CommentMode.Reply) { @@ -268,7 +258,7 @@ class Comment extends Component { this.blockUser((e.target as HTMLOptionElement).value as BlockTTL); }; - blockUser = (ttl: BlockTTL) => { + blockUser = debounce((ttl: BlockTTL) => { const { user } = this.props.data; const blockingDurations = getBlockingDurations(this.props.intl); const blockDuration = blockingDurations.find(el => el.value === ttl); @@ -284,7 +274,7 @@ class Comment extends Component { if (confirm(blockUser)) { this.props.blockUser!(user.id, user.name, ttl); } - }; + }, 100); onUnblockUserClick = () => { const { user } = this.props.data; @@ -786,7 +776,7 @@ class Comment extends Component { {(!props.collapsed || props.view === 'pinned') && (
- {!props.data.delete && !props.isCommentsDisabled && !props.disabled && !isGuest && props.view === 'main' && ( + {!props.data.delete && !props.isCommentsDisabled && !props.disabled && props.view === 'main' && (