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)
This commit is contained in:
Pavel Mineev
2020-03-12 15:44:31 -05:00
committed by Umputun
parent d85e185aa1
commit 93fd445bd4
40 changed files with 718 additions and 609 deletions
+2 -2
View File
@@ -69,8 +69,8 @@ export const logOut = (): Promise<void> =>
export const getConfig = (): Promise<Config> => fetcher.get(`/config`);
export const getPostComments = (sort: Sorting): Promise<Tree> =>
fetcher.get({
export const getPostComments = (sort: Sorting) =>
fetcher.get<Tree>({
url: `/find?site=${siteId}&url=${url}&sort=${sort}&format=tree`,
withCredentials: true,
});
+3
View File
@@ -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';
@@ -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;
}
@@ -1,3 +0,0 @@
export { AnonymousLoginForm } from './auth-panel__anonymous-login-form';
import './auth-panel__anonymous-login-form.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;
}
@@ -1,3 +0,0 @@
export { EmailLoginForm, EmailLoginFormConnected } from './auth-panel__email-login-form';
import './auth-panel__email-login-form.scss';
@@ -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<Props> = {
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<Props> = {
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('<AuthPanel />', () => {
describe('For not authorized user', () => {
it('should render login form with google and github provider', () => {
const element = mount(
<IntlProvider locale="en" messages={enMessages}>
<AuthPanel {...(DefaultProps as Props)} user={null} />
</IntlProvider>
);
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(
<IntlProvider locale="en" messages={enMessages}>
<AuthPanel
{...(DefaultProps as Props)}
providers={['google', 'github', 'yandex']}
provider={{ name: 'github' }}
user={null}
/>
</IntlProvider>
);
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(
<IntlProvider locale="en" messages={enMessages}>
<AuthPanel
{...(DefaultProps as Props)}
providers={['google', 'github', 'yandex']}
provider={{ name: 'baidu' }}
user={null}
/>
</IntlProvider>
);
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(
<IntlProvider locale="en" messages={enMessages}>
<AuthPanel
{...(DefaultProps as Props)}
user={null}
postInfo={{ ...DefaultProps.postInfo, read_only: true } as PostInfo}
/>
</IntlProvider>
);
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<typeof mockStore> = mockStore(initialStore)) =>
mount(
<IntlProvider locale="en" messages={enMessages}>
<Provider store={store}>
<AuthPanel {...props} />
</Provider>
</IntlProvider>
);
describe('For not authorized : null', () => {
it('should not render settings if there is no hidden users', () => {
const element = mount(
<IntlProvider locale="en" messages={enMessages}>
<AuthPanel
{...(DefaultProps as Props)}
user={null}
postInfo={{ ...DefaultProps.postInfo, read_only: true } as PostInfo}
/>
</IntlProvider>
);
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('<AuthPanel />', () => {
});
it('should render settings if there is some hidden users', () => {
const element = mount(
<IntlProvider locale="en" messages={enMessages}>
<AuthPanel
{...(DefaultProps as Props)}
user={null}
postInfo={{ ...DefaultProps.postInfo, read_only: true } as PostInfo}
hiddenUsers={{ hidden_joe: {} as any }}
/>
</IntlProvider>
);
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(
<IntlProvider locale="en" messages={enMessages}>
<AuthPanel {...(DefaultProps as Props)} user={{ id: `john`, name: 'John' } as User} />
</IntlProvider>
);
const element = createWrapper({
...DefaultProps,
user: { id: 'john', name: 'John' },
} as Props);
const authPanelColumn = element.find('.auth-panel__column');
@@ -167,11 +107,10 @@ describe('<AuthPanel />', () => {
});
describe('For admin user', () => {
it('should render admin action', () => {
const element = mount(
<IntlProvider locale="en" messages={enMessages}>
<AuthPanel {...(DefaultProps as Props)} user={{ id: `test`, admin: true, name: 'John' } as User} />{' '}
</IntlProvider>
);
const element = createWrapper({
...DefaultProps,
user: { id: 'test', admin: true, name: 'John' },
} as Props);
const adminAction = element.find('.auth-panel__admin-action').first();
+57 -242
View File
@@ -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<void>;
onSignIn(p: AuthProvider): Promise<User | null>;
@@ -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<Props, State> {
emailLoginRef = createRef<EmailLoginFormRef>();
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<Props, State> {
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 (
<div className="auth-panel__column">
<Fragment>
<FormattedMessage id="authPanel.logged-as" defaultMessage="You logged in as" />{' '}
<Dropdown title={user.name} titleClass="auth-panel__user-dropdown-title" theme={theme}>
<DropdownItem separator={!isUserAnonymous}>
@@ -188,124 +116,7 @@ export class AuthPanel extends Component<Props, State> {
<Button kind="link" theme={theme} onClick={onSignOut}>
<FormattedMessage id="authPanel.logout" defaultMessage="Logout?" />
</Button>
</div>
);
};
renderProvider = (provider: AuthProvider['name'], dropdown = false) => {
if (provider === 'anonymous') {
const anonymous = this.props.intl.formatMessage(authPanelMessages.anonymousProvider);
return (
<Dropdown
title={anonymous}
titleClass={dropdown ? 'auth-panel__dropdown-provider' : ''}
theme={this.props.theme}
>
<DropdownItem>
<AnonymousLoginForm
onSubmit={this.handleAnonymousLoginFormSubmut}
theme={this.props.theme}
className="auth-panel__anonymous-login-form"
intl={this.props.intl}
/>
</DropdownItem>
</Dropdown>
);
}
if (provider === 'email') {
return (
<Dropdown
title={PROVIDER_NAMES['email']}
titleClass={dropdown ? 'auth-panel__dropdown-provider' : ''}
theme={this.props.theme}
onTitleClick={this.onEmailTitleClick}
>
<DropdownItem>
<EmailLoginFormConnected
ref={this.emailLoginRef}
onSignIn={this.onEmailSignIn}
theme={this.props.theme}
className="auth-panel__email-login-form"
/>
</DropdownItem>
</Dropdown>
);
}
return (
<Button
mix={dropdown ? 'auth-panel__dropdown-provider' : ''}
kind="link"
data-provider={provider}
{...getHandleClickProps(this.handleOAuthLogin)}
role="link"
>
{PROVIDER_NAMES[provider]}
</Button>
);
};
renderOther = (providers: AuthProvider['name'][]) => {
const other = this.props.intl.formatMessage(authPanelMessages.otherProvider);
return (
<Dropdown title={other} theme={this.props.theme} onTitleClick={this.onEmailTitleClick}>
{providers.map(provider => (
<DropdownItem>{this.renderProvider(provider, true)}</DropdownItem>
))}
</Dropdown>
);
};
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 (
<div className="auth-panel__column">
<FormattedMessage id="authPanel.login" defaultMessage="Login:" />{' '}
{!isAboveThreshold &&
sortedProviders.map((provider, i) => {
const comma = i === 0 ? '' : i === sortedProviders.length - 1 ? ` ${or} ` : ', ';
return (
<span>
{comma}
{this.renderProvider(provider)}
</span>
);
})}
{isAboveThreshold &&
sortedProviders.slice(0, threshold - 1).map((provider, i) => {
const comma = i === 0 ? '' : ', ';
return (
<span>
{comma}
{this.renderProvider(provider)}
</span>
);
})}
{isAboveThreshold && (
<span>
{` ${or} `}
{this.renderOther(sortedProviders.slice(threshold - 1))}
</span>
)}
</div>
</Fragment>
);
};
@@ -342,7 +153,7 @@ export class AuthPanel extends Component<Props, State> {
<Button
kind="link"
mix="auth-panel__admin-action"
{...getHandleClickProps(() => this.toggleBlockedVisibility())}
{...getHandleClickProps(this.toggleBlockedVisibility)}
role="link"
>
{this.state.isBlockedVisible ? (
@@ -401,30 +212,21 @@ export class AuthPanel extends Component<Props, State> {
);
};
render(props: Props, { isBlockedVisible }: State) {
const {
user,
postInfo: { read_only },
theme,
} = props;
render({ user, postInfo, theme }: Props, { isBlockedVisible }: State) {
const { read_only } = postInfo;
const isAdmin = user && user.admin;
const isSettingsLabelVisible = Object.keys(this.props.hiddenUsers).length > 0 || isAdmin || isBlockedVisible;
return (
<div className={b('auth-panel', {}, { theme, loggedIn: !!user })}>
{this.renderAuthorized()}
{this.renderUnauthorized()}
<div className="auth-panel__column">{user ? this.renderAuthorized(user) : read_only && <Auth />}</div>
{this.renderThirdPartyWarning()}
{this.renderCookiesWarning()}
<div className="auth-panel__column">
{isSettingsLabelVisible && this.renderSettingsLabel()}
{isSettingsLabelVisible && ' • '}
{isAdmin && this.renderReadOnlySwitch()}
{isAdmin && ' • '}
{!isAdmin && read_only && (
<span className="auth-panel__readonly-label">
<FormattedMessage id="authPanel.read-only" defaultMessage="Read-only" />
@@ -522,7 +324,20 @@ function getSortArray(currentSort: Sorting, intl: IntlShape) {
});
}
export const AuthPanelWithIntl = (props: PropsWithoutIntl) => {
export default function(props: OwnProps) {
const intl = useIntl();
return <AuthPanel intl={intl} {...props} />;
};
const theme = useTheme();
const provider = useSelector<StoreState, ProviderState>(state => state.provider);
const sort = useSelector<StoreState, Sorting>(state => state.comments.sort);
return (
<AuthPanel
intl={intl}
theme={theme}
providers={StaticStore.config.auth_providers}
provider={provider}
sort={sort}
{...props}
/>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
export { AuthPanelWithIntl as AuthPanel } from './auth-panel';
export { default } from './auth-panel';
import './auth-panel.scss';
@@ -0,0 +1,18 @@
.auth-anonymous-login-form {
padding: 0.5em 0.7em;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
}
.auth-anonymous-login-form__input {
width: 9em;
}
.auth-anonymous-login-form__remember-me {
display: none;
}
.auth-anonymous-login-form__submit {
margin-left: 0.5em;
}
@@ -89,9 +89,9 @@ export class AnonymousLoginForm extends Component<Props, State> {
const props = this.props;
const intl = props.intl;
// TODO: will be great to `b` to accept `string | undefined | (string|undefined)[]` as classname
let className = b('auth-panel-anonymous-login-form', {}, { theme: props.theme });
let className = b('auth-anonymous-login-form', {}, { theme: props.theme });
if (props.className) {
className += ' ' + b('auth-panel-anonymous-login-form', {}, { theme: props.theme });
className += ' ' + b('auth-anonymous-login-form', {}, { theme: props.theme });
}
const usernameInvalidReason = this.getUsernameInvalidReason();
@@ -100,14 +100,14 @@ export class AnonymousLoginForm extends Component<Props, State> {
<form className={className} onSubmit={this.onSubmit}>
<Input
ref={this.inputRef}
mix="auth-panel-anonymous-login-form__input"
mix="auth-anonymous-login-form__input"
placeholder={intl.formatMessage(messages.userName)}
value={this.state.inputValue}
onInput={this.onChange}
/>
{/* honeypot input */}
<input
className="auth-panel-anonymous-login-form__remember-me"
className="auth-anonymous-login-form__remember-me"
type="checkbox"
tabIndex={-1}
autocomplete="off"
@@ -115,7 +115,7 @@ export class AnonymousLoginForm extends Component<Props, State> {
checked={this.state.honeyPotValue}
/>
<Button
mix="auth-panel-anonymous-login-form__submit"
mix="auth-anonymous-login-form__submit"
type="submit"
kind="primary"
size="middle"
@@ -0,0 +1,3 @@
export { AnonymousLoginForm } from './auth__anonymous-login-form';
import './auth__anonymous-login-form.scss';
@@ -1,17 +1,17 @@
.auth-panel-email-login-form {
.auth-email-login-form {
padding: 0.35em 0.55em;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
}
.auth-panel-email-login-form__input,
.auth-panel-email-login-form__token-input {
.auth-email-login-form__input,
.auth-email-login-form__token-input {
width: 12rem;
margin: 2px;
}
.auth-panel-email-login-form__token-input {
.auth-email-login-form__token-input {
resize: vertical;
border: 1px solid var(--color31);
padding: 4px;
@@ -27,11 +27,11 @@
}
}
.auth-panel-email-login-form__submit {
.auth-email-login-form__submit {
margin: 0.3rem 2px 2px;
}
.auth-panel-email-login-form__back-button {
.auth-email-login-form__back-button {
text-align: left;
margin-left: 0.1rem;
margin-bottom: 0.5rem;
@@ -43,19 +43,19 @@
}
}
.auth-panel-email-login-form__error {
.auth-email-login-form__error {
margin: 4px 2px;
padding: 6px 8px;
font-weight: normal;
line-height: 1.2;
}
.auth-panel-email-login-form_theme_dark .auth-panel-email-login-form__error {
.auth-email-login-form_theme_dark .auth-email-login-form__error {
background: var(--color28);
color: var(--color27);
}
.auth-panel-email-login-form_theme_light .auth-panel-email-login-form__error {
.auth-email-login-form_theme_light .auth-email-login-form__error {
background: var(--color26);
color: var(--color25);
}
@@ -1,7 +1,7 @@
/** @jsx createElement */
import { createElement } from 'preact';
import { mount, ReactWrapper } from 'enzyme';
import { EmailLoginFormConnected as EmailLoginForm, Props, State } from './auth-panel__email-login-form';
import { EmailLoginFormConnected as EmailLoginForm, Props, State } from './auth__email-login-form';
import { User } from '@app/common/types';
import { sleep } from '@app/utils/sleep';
import { validToken } from '@app/testUtils/mocks/jwt';
@@ -91,6 +91,6 @@ describe('EmailLoginForm', () => {
wrapper.find('textarea').getDOMNode<HTMLTextAreaElement>().value = validToken;
wrapper.find('textarea').simulate('input');
expect(wrapper.find('.auth-panel-email-login-form__error').text()).toBe('Token is expired');
expect(wrapper.find('.auth-email-login-form__error').text()).toBe('Token is expired');
});
});
@@ -13,7 +13,7 @@ import { Button } from '@app/components/button';
import { isJwtExpired } from '@app/utils/jwt';
import { defineMessages, IntlShape, useIntl, FormattedMessage } from 'react-intl';
import { messages as loginForm } from '../__anonymous-login-form/auth-panel__anonymous-login-form';
import { messages as loginForm } from '../__anonymous-login-form/auth__anonymous-login-form';
interface OwnProps {
onSignIn(token: string): Promise<User | null>;
@@ -192,9 +192,9 @@ export class EmailLoginForm extends Component<Props, State> {
render(props: Props) {
const intl = props.intl;
// TODO: will be great to `b` to accept `string | undefined | (string|undefined)[]` as classname
let className = b('auth-panel-email-login-form', {}, { theme: props.theme });
let className = b('auth-email-login-form', {}, { theme: props.theme });
if (props.className) {
className += ' ' + b('auth-panel-email-login-form', {}, { theme: props.theme });
className += ' ' + b('auth-email-login-form', {}, { theme: props.theme });
}
const form1InvalidReason = this.getForm1InvalidReason();
@@ -205,23 +205,23 @@ export class EmailLoginForm extends Component<Props, State> {
<Input
autoFocus
name="username"
mix="auth-panel-email-login-form__input"
mix="auth-email-login-form__input"
ref={this.usernameInputRef}
placeholder={intl.formatMessage(loginForm.userName)}
value={this.state.usernameValue}
onInput={this.onUsernameChange}
/>
<Input
mix="auth-panel-email-login-form__input"
mix="auth-email-login-form__input"
type="email"
name="email"
placeholder={intl.formatMessage(messages.emailAddress)}
value={this.state.addressValue}
onInput={this.onAddressChange}
/>
{this.state.error && <div className="auth-panel-email-login-form__error">{this.state.error}</div>}
{this.state.error && <div className="auth-email-login-form__error">{this.state.error}</div>}
<Button
mix="auth-panel-email-login-form__submit"
mix="auth-email-login-form__submit"
kind="primary"
size="middle"
type="submit"
@@ -237,13 +237,13 @@ export class EmailLoginForm extends Component<Props, State> {
return (
<form className={className} onSubmit={this.onSubmit}>
<Button kind="link" mix="auth-panel-email-login-form__back-button" {...getHandleClickProps(this.goBack)}>
<Button kind="link" mix="auth-email-login-form__back-button" {...getHandleClickProps(this.goBack)}>
<FormattedMessage id="emailLoginForm.back" defaultMessage="Back" />
</Button>
<TextareaAutosize
autofocus={true}
name="token"
className="auth-panel-email-login-form__token-input"
className="auth-email-login-form__token-input"
ref={this.tokenRef}
placeholder={intl.formatMessage(messages.token)}
value={this.state.tokenValue}
@@ -251,9 +251,9 @@ export class EmailLoginForm extends Component<Props, State> {
spellcheck={false}
autocomplete="off"
/>
{this.state.error && <div className="auth-panel-email-login-form__error">{this.state.error}</div>}
{this.state.error && <div className="auth-email-login-form__error">{this.state.error}</div>}
<Button
mix="auth-panel-email-login-form__submit"
mix="auth-email-login-form__submit"
type="submit"
kind="primary"
size="middle"
@@ -0,0 +1,3 @@
import './auth__email-login-form.scss';
export { EmailLoginForm, EmailLoginFormConnected, EmailLoginFormRef } from './auth__email-login-form';
@@ -0,0 +1,4 @@
.auth {
font-size: 14px;
font-weight: 700;
}
@@ -0,0 +1,70 @@
/** @jsx createElement */
import { createElement } from 'preact';
import { IntlProvider } from 'react-intl';
import { Provider } from 'react-redux';
import enMessages from '@app/locales/en.json';
import { mockStore } from '@app/testUtils/mockStore';
import { StaticStore } from '@app/common/static_store';
import Auth from './auth';
import { mount } from 'enzyme';
import { Button } from '../button';
import { StoreState } from '@app/store';
const initialStore = {
provider: { name: 'google' },
theme: 'light',
comments: {
sort: '-score',
} as StoreState['comments'],
} as const;
describe('<Auth/>', () => {
const createWrapper = (store?: Partial<StoreState>) =>
mount(
<IntlProvider locale="en" messages={enMessages}>
<Provider store={mockStore(store || initialStore)}>
<Auth />
</Provider>
</IntlProvider>
);
it('should render login form with google and github provider', () => {
StaticStore.config.auth_providers = ['google', 'github'];
const element = createWrapper();
const providersButtons = element.find(Button);
expect(element.text()).toEqual(expect.stringContaining('Login:'));
expect(providersButtons.at(0).text()).toEqual('Google');
expect(providersButtons.at(1).text()).toEqual('GitHub');
});
describe('providers sorting', () => {
it('should do nothing if provider not found', () => {
StaticStore.config.auth_providers = ['google', 'github'];
const element = createWrapper({
...initialStore,
provider: { name: 'baidu' },
});
const providerLinks = element.find(Button);
expect(providerLinks.at(0).text()).toEqual('Google');
expect(providerLinks.at(1).text()).toEqual('GitHub');
});
it('should place selected provider first', () => {
StaticStore.config.auth_providers = ['google', 'github'];
const element = createWrapper({
...initialStore,
provider: { name: 'github' },
});
const providerLinks = element.find(Button);
expect(providerLinks.at(0).text()).toEqual('GitHub');
expect(providerLinks.at(1).text()).toEqual('Google');
});
});
});
+198
View File
@@ -0,0 +1,198 @@
/** @jsx createElement */
import { createElement, Component, createRef } from 'preact';
import { useCallback } from 'preact/hooks';
import { IntlShape, FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { AuthProvider, Theme, User } from '@app/common/types';
import { PROVIDER_NAMES, IS_STORAGE_AVAILABLE } from '@app/common/constants';
import { getHandleClickProps } from '@app/common/accessibility';
import { Button } from '@app/components/button';
import { Dropdown, DropdownItem } from '@app/components/dropdown';
import debounce from '@app/utils/debounce';
import { ProviderState } from '@app/store/provider/reducers';
import { StaticStore } from '@app/common/static_store';
import { useSelector, useDispatch } from 'react-redux';
import { StoreState } from '@app/store';
import useTheme from '@app/hooks/useTheme';
import { logIn } from '@app/store/user/actions';
import { AnonymousLoginForm } from './__anonymous-login-form';
import { EmailLoginFormConnected, EmailLoginFormRef } from './__email-login-form';
import styles from './auth.module.pcss';
interface Props {
intl: IntlShape;
theme: Theme;
onSignIn(provider: AuthProvider): any; // eslint-disable-line
user: User | null;
provider: ProviderState;
}
interface State {
threshold: number;
}
class Auth extends Component<Props, State> {
emailLoginRef = createRef<EmailLoginFormRef>();
singInMessageAndSortWidth = 255;
state = {
threshold: 3,
};
componentWillMount() {
this.resizeHandler();
window.addEventListener('resize', this.resizeHandler);
}
componentWillUnmount() {
window.removeEventListener('resize', this.resizeHandler);
}
resizeHandler = debounce(() => {
this.setState({
threshold: Math.max(3, Math.round((window.innerWidth - this.singInMessageAndSortWidth) / 80)),
});
}, 100);
onEmailTitleClick = () => {
this.emailLoginRef.current && this.emailLoginRef.current.focus();
};
onEmailSignIn = (token: string) => {
return this.props.onSignIn({ name: 'email', token });
};
handleOAuthLogin = async (e: MouseEvent | KeyboardEvent) => {
const name = (e.target as HTMLButtonElement).dataset.provider! as AuthProvider['name'];
this.props.onSignIn({ name } as AuthProvider);
};
handleAnonymousLoginFormSubmut = async (username: string) => {
this.props.onSignIn({ name: 'anonymous', username });
};
renderOther = (providers: AuthProvider['name'][]) => {
const other = this.props.intl.formatMessage(authPanelMessages.otherProvider);
return (
<Dropdown title={other} theme={this.props.theme} onTitleClick={this.onEmailTitleClick}>
{providers.map(provider => (
<DropdownItem>{this.renderProvider(provider)}</DropdownItem>
))}
</Dropdown>
);
};
renderProvider = (provider: AuthProvider['name']) => {
if (provider === 'anonymous') {
const anonymous = this.props.intl.formatMessage(authPanelMessages.anonymousProvider);
return (
<Dropdown title={anonymous} theme={this.props.theme}>
<DropdownItem>
<AnonymousLoginForm
onSubmit={this.handleAnonymousLoginFormSubmut}
theme={this.props.theme}
intl={this.props.intl}
/>
</DropdownItem>
</Dropdown>
);
}
if (provider === 'email') {
return (
<Dropdown title={PROVIDER_NAMES['email']} theme={this.props.theme} onTitleClick={this.onEmailTitleClick}>
<DropdownItem>
<EmailLoginFormConnected ref={this.emailLoginRef} onSignIn={this.onEmailSignIn} theme={this.props.theme} />
</DropdownItem>
</Dropdown>
);
}
return (
<Button kind="link" data-provider={provider} {...getHandleClickProps(this.handleOAuthLogin)} role="link">
{PROVIDER_NAMES[provider]}
</Button>
);
};
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 (
<div className={styles.auth}>
<FormattedMessage id="authPanel.login" defaultMessage="Login:" />{' '}
{!isAboveThreshold &&
sortedProviders.map((provider, i) => {
const comma = i === 0 ? '' : i === sortedProviders.length - 1 ? ` ${or} ` : ', ';
return (
<span>
{comma}
{this.renderProvider(provider)}
</span>
);
})}
{isAboveThreshold &&
sortedProviders.slice(0, threshold - 1).map((provider, i) => {
const comma = i === 0 ? '' : ', ';
return (
<span>
{comma}
{this.renderProvider(provider)}
</span>
);
})}
{isAboveThreshold && (
<span>
{` ${or} `}
{this.renderOther(sortedProviders.slice(threshold - 1))}
</span>
)}
</div>
);
}
}
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<StoreState, ProviderState>(store => store.provider);
const user = useSelector<StoreState, User | null>(store => store.user);
const theme = useTheme();
const intl = useIntl();
const handleSignin = useCallback((provider: AuthProvider) => dispatch(logIn(provider)), []);
return <Auth provider={provider} theme={theme} onSignIn={handleSignin} intl={intl} user={user} />;
}
+1
View File
@@ -0,0 +1 @@
export { default } from './auth';
@@ -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;
}
@@ -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;
}
}
@@ -5,6 +5,7 @@
$lines: 4;
$height: calc($fontSize * $lineHeight * $lines + $paddingVrt * 2);
display: block;
box-sizing: border-box;
width: 100%;
height: $height;
@@ -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);
@@ -1,3 +1,4 @@
.comment-form__markdown {
margin-bottom: 5px;
font-size: 12px;
}
@@ -1,5 +1,4 @@
.comment-form__rss {
margin-top: 8px;
font-size: 12px;
line-height: 1;
}
@@ -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;
@@ -14,6 +14,7 @@ const DEFAULT_PROPS: Readonly<Omit<Props, 'intl'>> = {
onSubmit: () => Promise.resolve(),
getPreview: () => Promise.resolve(''),
user: null,
id: '1',
};
const intl = {
@@ -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<Props, State> {
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<Props, State> {
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<Props, State> {
}
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<Props, State> {
isErrorShown: false,
errorMessage: null,
preview: null,
text: (e.target as HTMLInputElement).value,
text: value,
});
}
@@ -380,6 +399,22 @@ export class CommentForm extends Component<Props, State> {
this.setState({ errorLock: false, isDisabled: false, buttonText: null });
}
renderMarkdownTip = () => (
<div className="comment-form__markdown">
<FormattedMessage
id="commentForm.notice-about-styling"
defaultMessage="Styling with <a>Markdown</a> is supported"
values={{
a: (title: string) => (
<a class="comment-form__markdown-link" target="_blank" href="markdown-help.html">
{title}
</a>
),
}}
/>
</div>
);
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, State> {
))}
<div className="comment-form__actions">
<div>
{!props.simpleView && (
<Button
kind="secondary"
theme={props.theme}
size="large"
mix="comment-form__button"
disabled={isDisabled}
onClick={this.getPreview}
>
<FormattedMessage id="commentForm.preview" defaultMessage="Preview" />
</Button>
)}
<Button kind="primary" size="large" mix="comment-form__button" type="submit" disabled={isDisabled}>
{label}
</Button>
</div>
{!props.simpleView && props.mode === 'main' && (
<div className="comment-form__rss">
<div className="comment-form__markdown">
<FormattedMessage
id="commentForm.notice-about-styling"
defaultMessage="Styling with <a>Markdown</a> is supported"
values={{
a: (title: string) => (
<a class="comment-form__markdown-link" target="_blank" href="markdown-help.html">
{title}
</a>
),
}}
/>
{this.props.user ? (
<Fragment>
<div>
{!props.simpleView && (
<Button
kind="secondary"
theme={props.theme}
size="large"
mix="comment-form__button"
disabled={isDisabled}
onClick={this.getPreview}
>
<FormattedMessage id="commentForm.preview" defaultMessage="Preview" />
</Button>
)}
<Button kind="primary" size="large" mix="comment-form__button" type="submit" disabled={isDisabled}>
{label}
</Button>
</div>
<FormattedMessage id="commentForm.subscribe-by" defaultMessage="Subscribe by" />{' '}
<SubscribeByRSS userId={props.user !== null ? props.user.id : null} />
{StaticStore.config.email_notifications && (
<Fragment>
{' '}
<FormattedMessage id="commentForm.subscribe-or" defaultMessage="or" /> <SubscribeByEmail />
</Fragment>
{!props.simpleView && props.mode === 'main' && (
<div className="comment-form__rss">
{this.renderMarkdownTip()}
<FormattedMessage id="commentForm.subscribe-by" defaultMessage="Subscribe by" />{' '}
<SubscribeByRSS userId={props.user !== null ? props.user.id : null} />
{StaticStore.config.email_notifications && (
<Fragment>
{' '}
<FormattedMessage id="commentForm.subscribe-or" defaultMessage="or" /> <SubscribeByEmail />
</Fragment>
)}
</div>
)}
</div>
</Fragment>
) : (
<Fragment>
<Auth />
{this.renderMarkdownTip()}
</Fragment>
)}
</div>
+31 -39
View File
@@ -141,45 +141,10 @@ export interface State {
}
class Comment extends Component<Props, State> {
votingPromise: Promise<unknown>;
votingPromise: Promise<unknown> = Promise.resolve();
/** comment text node. Used in comment text copying */
textNode = createRef<HTMLDivElement>();
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<State> = {
scoreDelta: props.data.vote,
@@ -206,6 +171,31 @@ class Comment extends Component<Props, State> {
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<Props, State> {
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<Props, State> {
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, State> {
{(!props.collapsed || props.view === 'pinned') && (
<div className="comment__actions">
{!props.data.delete && !props.isCommentsDisabled && !props.disabled && !isGuest && props.view === 'main' && (
{!props.data.delete && !props.isCommentsDisabled && !props.disabled && props.view === 'main' && (
<Button kind="link" {...getHandleClickProps(this.toggleReplying)} mix="comment__action">
{isReplying ? (
<FormattedMessage id="comment.cancel" defaultMessage="Cancel" />
@@ -841,6 +831,7 @@ class Comment extends Component<Props, State> {
{CommentForm && isReplying && props.view === 'main' && (
<CommentForm
id={o.id}
intl={this.props.intl}
user={props.user}
theme={props.theme}
@@ -858,6 +849,7 @@ class Comment extends Component<Props, State> {
{CommentForm && isEditing && props.view === 'main' && (
<CommentForm
id={o.id}
intl={this.props.intl}
user={props.user}
theme={props.theme}
+25 -65
View File
@@ -4,16 +4,9 @@ import { useSelector } from 'react-redux';
import b from 'bem-react-helper';
import { IntlShape, useIntl, FormattedMessage, defineMessages } from 'react-intl';
import { User, Sorting, AuthProvider } from '@app/common/types';
import {
COMMENT_NODE_CLASSNAME_PREFIX,
MAX_SHOWN_ROOT_COMMENTS,
THEMES,
IS_MOBILE,
LS_SORT_KEY,
DEFAULT_SORT,
} from '@app/common/constants';
import { maxShownComments } from '@app/common/settings';
import { AuthProvider, Sorting } from '@app/common/types';
import { COMMENT_NODE_CLASSNAME_PREFIX, MAX_SHOWN_ROOT_COMMENTS, THEMES, IS_MOBILE } from '@app/common/constants';
import { maxShownComments, url } from '@app/common/settings';
import { StaticStore } from '@app/common/static_store';
import { StoreState } from '@app/store';
@@ -27,12 +20,12 @@ import {
hideUser,
unhideUser,
} from '@app/store/user/actions';
import { fetchComments } from '@app/store/comments/actions';
import { fetchComments, updateSorting } from '@app/store/comments/actions';
import { setCommentsReadOnlyState } from '@app/store/post_info/actions';
import { setTheme } from '@app/store/theme/actions';
import { addComment, updateComment } from '@app/store/comments/actions';
import { AuthPanel } from '@app/components/auth-panel';
import AuthPanel from '@app/components/auth-panel';
import Settings from '@app/components/settings';
import { ConnectedComment as Comment } from '@app/components/comment/connected-comment';
import { CommentForm } from '@app/components/comment-form';
@@ -44,13 +37,13 @@ import { isUserAnonymous } from '@app/utils/isUserAnonymous';
import { bindActions } from '@app/utils/actionBinder';
import postMessage from '@app/utils/postMessage';
import { useActions } from '@app/hooks/useAction';
import * as localStorage from '@app/common/local-storage';
const mapStateToProps = (state: StoreState) => ({
sort: state.comments.sort,
isCommentsLoading: state.comments.isFetching,
user: state.user,
topComments: state.topComments,
pinnedComments: state.pinnedComments.map(id => state.comments[id]).filter(c => !c.hidden),
provider: state.provider,
topComments: state.comments.topComments,
pinnedComments: state.comments.pinnedComments.map(id => state.comments.allComments[id]).filter(c => !c.hidden),
theme: state.theme,
info: state.info,
hiddenUsers: state.hiddenUsers,
@@ -60,6 +53,7 @@ const mapStateToProps = (state: StoreState) => ({
});
const boundActions = bindActions({
updateSorting,
fetchComments,
fetchUser,
fetchBlockedUsers,
@@ -78,9 +72,7 @@ const boundActions = bindActions({
type Props = ReturnType<typeof mapStateToProps> & typeof boundActions & { intl: IntlShape };
interface State {
sort: string;
isUserLoading: boolean;
isCommentsListLoading: boolean;
isSettingsVisible: boolean;
commentsShown: number;
wasSomeoneUnblocked: boolean;
@@ -93,22 +85,10 @@ const messages = defineMessages({
},
});
function getInitialSort() {
const sort = localStorage.getItem(LS_SORT_KEY) as Sorting;
if (sort) {
return sort;
}
return DEFAULT_SORT;
}
/** main component fr main comments widget */
export class Root extends Component<Props, State> {
state = {
sort: getInitialSort(),
isUserLoading: true,
isCommentsListLoading: true,
commentsShown: maxShownComments,
wasSomeoneUnblocked: false,
isSettingsVisible: false,
@@ -117,9 +97,8 @@ export class Root extends Component<Props, State> {
componentWillMount() {
const userloading = this.props.fetchUser().finally(() => this.setState({ isUserLoading: false }));
Promise.all([userloading, this.fetchComments()]).finally(() => {
Promise.all([userloading, this.props.fetchComments()]).finally(() => {
postMessage({ remarkIframeHeight: document.body.offsetHeight });
this.setState({ isCommentsListLoading: false });
setTimeout(this.checkUrlHash);
window.addEventListener('hashchange', this.checkUrlHash);
});
@@ -127,36 +106,23 @@ export class Root extends Component<Props, State> {
window.addEventListener('message', this.onMessage.bind(this));
}
fetchComments() {
return this.props.fetchComments(this.state.sort);
}
changeSort = async (sort: Sorting) => {
if (sort === this.state.sort) return;
const prevSort = this.state.sort;
if (sort === this.props.sort) return;
this.setState({ isCommentsListLoading: true, sort }, async () => {
try {
await this.fetchComments();
localStorage.setItem(LS_SORT_KEY, sort);
this.setState({ isCommentsListLoading: false });
} catch (e) {
this.setState({ sort: prevSort, isCommentsListLoading: false });
}
});
await this.props.updateSorting(sort);
};
logIn = async (provider: AuthProvider): Promise<User | null> => {
logIn = async (provider: AuthProvider) => {
const user = await this.props.logIn(provider);
await this.fetchComments();
await this.props.fetchComments();
return user;
};
logOut = async (): Promise<void> => {
logOut = async () => {
await this.props.logOut();
await this.fetchComments();
await this.props.fetchComments();
};
checkUrlHash(e: Event & { newURL?: string }) {
@@ -196,7 +162,7 @@ export class Root extends Component<Props, State> {
onBlockedUsersHide = async () => {
// if someone was unblocked let's reload comments
if (this.state.wasSomeoneUnblocked) {
this.fetchComments();
this.props.fetchComments();
}
this.setState({
wasSomeoneUnblocked: false,
@@ -217,36 +183,29 @@ export class Root extends Component<Props, State> {
/**
* Defines whether current client is logged in via `Anonymous provider`
*/
isAnonymous(): boolean {
return isUserAnonymous(this.props.user);
}
isAnonymous = () => isUserAnonymous(this.props.user);
render(props: Props, { isUserLoading, isCommentsListLoading, commentsShown, isSettingsVisible }: State) {
render(props: Props, { isUserLoading, commentsShown, isSettingsVisible }: State) {
if (isUserLoading) {
return <Preloader mix="root__preloader" />;
}
const isGuest = !props.user;
const isCommentsDisabled = props.info.read_only!;
const imageUploadHandler = this.isAnonymous() ? undefined : this.props.uploadImage;
return (
<Fragment>
<AuthPanel
theme={this.props.theme}
user={this.props.user}
hiddenUsers={this.props.hiddenUsers}
sort={this.state.sort}
onSortChange={this.changeSort}
isCommentsDisabled={isCommentsDisabled}
postInfo={this.props.info}
providers={StaticStore.config.auth_providers}
provider={this.props.provider}
onSignIn={this.logIn}
onSignOut={this.logOut}
onBlockedUsersShow={this.onBlockedUsersShow}
onBlockedUsersHide={this.onBlockedUsersHide}
onCommentsChangeReadOnlyMode={this.props.setCommentsReadOnlyState}
onSortChange={this.changeSort}
/>
<div className="root__main">
{isSettingsVisible ? (
@@ -263,8 +222,9 @@ export class Root extends Component<Props, State> {
/>
) : (
<Fragment>
{!isGuest && !isCommentsDisabled && (
{!isCommentsDisabled && (
<CommentForm
id={encodeURI(url || '')}
intl={this.props.intl}
theme={props.theme}
mix="root__input"
@@ -298,7 +258,7 @@ export class Root extends Component<Props, State> {
</div>
)}
{!!this.props.topComments.length && !isCommentsListLoading && (
{!!this.props.topComments.length && !props.isCommentsLoading && (
<div className="root__threads" role="list">
{(IS_MOBILE && commentsShown < this.props.topComments.length
? this.props.topComments.slice(0, commentsShown)
@@ -321,7 +281,7 @@ export class Root extends Component<Props, State> {
</div>
)}
{isCommentsListLoading && (
{props.isCommentsLoading && (
<div className="root__threads" role="list">
<Preloader mix="root__preloader" />
</div>
@@ -1,6 +1,5 @@
.thread {
position: relative;
overflow: hidden;
}
.thread_indented {
+3 -2
View File
@@ -24,8 +24,9 @@ interface Props {
}
const commentSelector = (id: string) => (state: StoreState) => {
const { theme, comments, childComments } = state;
const comment = comments[id];
const { theme, comments } = state;
const { allComments, childComments } = comments;
const comment = allComments[id];
const childs = childComments[id];
const collapsed = getThreadIsCollapsed(comment)(state);
+33 -9
View File
@@ -4,7 +4,16 @@ import { Tree, Comment, CommentMode, Node, Sorting } from '@app/common/types';
import { StoreAction, StoreState } from '../index';
import { setPostInfo } from '../post_info/actions';
import { filterTree } from './utils';
import { COMMENTS_SET, COMMENT_MODE_SET, COMMENTS_APPEND, COMMENTS_EDIT, COMMENT_MODE_SET_ACTION } from './types';
import {
COMMENTS_SET,
COMMENT_MODE_SET,
COMMENTS_APPEND,
COMMENTS_EDIT,
COMMENT_MODE_SET_ACTION,
COMMENTS_SET_SORT,
COMMENTS_REQUEST_FETCHING,
COMMENTS_REQUEST_SUCCESS,
} from './types';
/** sets comments, and put pinned comments in cache */
export const setComments = (comments: Node[]): StoreAction<void> => dispatch => {
@@ -47,7 +56,7 @@ export const setPinState = (id: Comment['id'], value: boolean): StoreAction<Prom
} else {
await api.unpinComment(id);
}
let comment = getState().comments[id];
let comment = getState().comments.allComments[id];
comment = { ...comment, pin: value, edit: { summary: '', time: new Date().toISOString() } };
dispatch({ type: COMMENTS_EDIT, comment });
};
@@ -61,17 +70,18 @@ export const removeComment = (id: Comment['id']): StoreAction<Promise<void>> =>
} else {
await api.removeMyComment(id);
}
let comment = getState().comments[id];
let comment = getState().comments.allComments[id];
comment = { ...comment, delete: true, edit: { summary: '', time: new Date().toISOString() } };
dispatch({ type: COMMENTS_EDIT, comment });
};
/** fetches comments from server */
export const fetchComments = (sort: Sorting): StoreAction<Promise<Tree>> => async (dispatch, getState) => {
const { hiddenUsers } = getState();
export const fetchComments = (sort?: Sorting): StoreAction<Promise<Tree>> => async (dispatch, getState) => {
const { hiddenUsers, comments } = getState();
const hiddenUsersIds = Object.keys(hiddenUsers);
const data = await api.getPostComments(sort);
dispatch({ type: COMMENTS_REQUEST_FETCHING });
const data = await api.getPostComments(sort || comments.sort);
dispatch({ type: COMMENTS_REQUEST_SUCCESS });
if (hiddenUsersIds.length > 0) {
data.comments = filterTree(data.comments, node => hiddenUsersIds.indexOf(node.comment.user.id) === -1);
}
@@ -83,7 +93,7 @@ export const fetchComments = (sort: Sorting): StoreAction<Promise<Tree>> => asyn
};
/** sets mode for comment, either reply or edit */
export const setCommentMode = (mode: StoreState['activeComment']): StoreAction<void> => dispatch => {
export const setCommentMode = (mode: StoreState['comments']['activeComment']): StoreAction<void> => dispatch => {
if (mode !== null && mode.state === CommentMode.None) {
mode = null;
}
@@ -91,9 +101,23 @@ export const setCommentMode = (mode: StoreState['activeComment']): StoreAction<v
};
/** unsets comment mode */
export function unsetCommentMode(mode: StoreState['activeComment'] = null) {
export function unsetCommentMode(mode: StoreState['comments']['activeComment'] = null) {
return {
type: COMMENT_MODE_SET,
mode,
} as COMMENT_MODE_SET_ACTION;
}
export function updateSorting(sort: Sorting): StoreAction<void> {
return async (dispath, getState) => {
const { sort: prevSort } = getState().comments;
dispath({ type: COMMENTS_REQUEST_FETCHING });
dispath({ type: COMMENTS_SET_SORT, payload: sort });
try {
await dispath(fetchComments(sort));
} catch (e) {
dispath({ type: COMMENTS_SET_SORT, payload: prevSort });
}
};
}
+2 -2
View File
@@ -2,9 +2,9 @@ import { Comment, CommentMode } from '@app/common/types';
import { StoreState } from '../index';
export const getCommentMode = (id: Comment['id']) => (state: StoreState): CommentMode => {
if (state.activeComment === null || state.activeComment.id !== id) {
if (state.comments.activeComment === null || state.comments.activeComment.id !== id) {
return CommentMode.None;
}
return state.activeComment.state;
return state.comments.activeComment.state;
};
+40 -4
View File
@@ -1,4 +1,5 @@
import { Node, Comment, CommentMode } from '@app/common/types';
import { Node, Comment, CommentMode, Sorting } from '@app/common/types';
import { combineReducers } from 'redux';
import {
COMMENTS_SET,
@@ -11,8 +12,14 @@ import {
COMMENTS_EDIT,
COMMENTS_PATCH,
COMMENTS_PATCH_ACTION,
COMMENTS_SET_SORT,
COMMENTS_SET_SORT_ACTION,
COMMENTS_REQUEST_FETCHING,
COMMENTS_REQUEST_SUCCESS,
COMMENTS_REQUEST_FAILURE,
COMMENTS_REQUEST_ACTIONS,
} from './types';
import { getPinnedComments } from './utils';
import { getPinnedComments, getInitialSort } from './utils';
import { cmpRef } from '@app/utils/cmpRef';
export const topComments = (
@@ -87,7 +94,7 @@ const reduceComments = (c: Record<Comment['id'], Comment>, x: Node): Record<Comm
return c;
};
export const comments = (
export const allComments = (
state: Record<Comment['id'], Comment> = {},
action: COMMENTS_SET_ACTION | COMMENTS_APPEND_ACTION | COMMENTS_EDIT_ACTION | COMMENTS_PATCH_ACTION
): Record<Comment['id'], Comment> => {
@@ -170,4 +177,33 @@ export const pinnedComments = (
}
};
export default { topComments, childComments, comments, activeComment, pinnedComments };
function isFetching(state: boolean = false, action: COMMENTS_REQUEST_ACTIONS): boolean {
switch (action.type) {
case COMMENTS_REQUEST_FETCHING:
return true;
case COMMENTS_REQUEST_SUCCESS:
case COMMENTS_REQUEST_FAILURE:
return false;
default:
return state;
}
}
function sort(state: Sorting = getInitialSort(), action: COMMENTS_SET_SORT_ACTION): Sorting {
switch (action.type) {
case COMMENTS_SET_SORT:
return action.payload;
default:
return state;
}
}
export default combineReducers({
sort,
isFetching,
topComments,
childComments,
allComments,
activeComment,
pinnedComments,
});
+25 -3
View File
@@ -1,4 +1,4 @@
import { Node, Comment } from '@app/common/types';
import { Node, Comment, Sorting } from '@app/common/types';
import { StoreState } from '../index';
export const COMMENTS_SET = 'COMMENTS/SET';
@@ -34,7 +34,27 @@ export const COMMENT_MODE_SET = 'COMMENT_MODE/SET';
export interface COMMENT_MODE_SET_ACTION {
type: typeof COMMENT_MODE_SET;
mode: StoreState['activeComment'];
mode: StoreState['comments']['activeComment'];
}
export const COMMENTS_REQUEST_FETCHING = 'COMMENTS/FETCHING';
export const COMMENTS_REQUEST_SUCCESS = 'COMMENTS/FETCHING_SUCCESS';
export const COMMENTS_REQUEST_FAILURE = 'COMMENTS/FETCHING_FAILURE';
export type COMMENTS_REQUEST_ACTIONS_TYPE =
| typeof COMMENTS_REQUEST_FETCHING
| typeof COMMENTS_REQUEST_SUCCESS
| typeof COMMENTS_REQUEST_FAILURE;
export interface COMMENTS_REQUEST_ACTIONS {
type: COMMENTS_REQUEST_ACTIONS_TYPE;
}
export const COMMENTS_SET_SORT = 'COMMENTS/SET_SORT';
export interface COMMENTS_SET_SORT_ACTION {
type: typeof COMMENTS_SET_SORT;
payload: Sorting;
}
export type COMMENTS_ACTIONS =
@@ -42,4 +62,6 @@ export type COMMENTS_ACTIONS =
| COMMENTS_APPEND_ACTION
| COMMENTS_EDIT_ACTION
| COMMENTS_PATCH_ACTION
| COMMENT_MODE_SET_ACTION;
| COMMENT_MODE_SET_ACTION
| COMMENTS_SET_SORT_ACTION
| COMMENTS_REQUEST_ACTIONS;
+12 -1
View File
@@ -1,4 +1,5 @@
import { Comment, Node } from '@app/common/types';
import { Comment, Node, Sorting } from '@app/common/types';
import { LS_SORT_KEY, DEFAULT_SORT } from '@app/common/constants';
/**
* Filters tree node
@@ -40,3 +41,13 @@ export function findPinnedComments(thread: Node): Comment[] {
export function getPinnedComments(threads: Node[]): Comment[] {
return threads.reduce((acc: Comment[], thread: Node) => acc.concat(findPinnedComments(thread)), []);
}
export function getInitialSort() {
const sort = localStorage.getItem(LS_SORT_KEY) as Sorting;
if (sort) {
return sort;
}
return DEFAULT_SORT;
}
+1 -1
View File
@@ -8,7 +8,7 @@ import provider from './provider/reducers';
/** Merged store reducers */
export default {
...comments,
comments,
...postinfo,
...theme,
...user,
+2 -1
View File
@@ -14,7 +14,7 @@ import {
USER_SUBSCRIPTION_SET,
USER_SET_ACTION,
} from './types';
import { unsetCommentMode } from '../comments/actions';
import { unsetCommentMode, fetchComments } from '../comments/actions';
import { IS_STORAGE_AVAILABLE, LS_HIDDEN_USERS_KEY } from '@app/common/constants';
import { getItem } from '@app/common/local-storage';
import { updateProvider } from '../provider/actions';
@@ -38,6 +38,7 @@ export const logIn = (provider: AuthProvider): StoreAction<Promise<User | null>>
dispatch(updateProvider({ name: provider.name }));
dispatch(setUser(user));
dispatch(fetchComments());
return user;
};