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:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
-18
@@ -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();
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
+5
-5
@@ -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';
|
||||
+9
-9
@@ -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);
|
||||
}
|
||||
+2
-2
@@ -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');
|
||||
});
|
||||
});
|
||||
+11
-11
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+5
-2
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import provider from './provider/reducers';
|
||||
|
||||
/** Merged store reducers */
|
||||
export default {
|
||||
...comments,
|
||||
comments,
|
||||
...postinfo,
|
||||
...theme,
|
||||
...user,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user