Auth login design (#390)
* break auth panel render into submethods * move var definition * break renderUnathorized into submethods * hide login providers behind dropdown if they are exceed length of 3 * amend dropdown to behave nicely being placed in another dropdown * add style to providers enclosed in dropdown * add provider reducer and actions * add provider save/restore to app flow * place last login provider first in providers list * infer StoreState from combineReducers return type * move collapsed threads retoration to action * fix: provider lost in other * add dynamic threshold depending on window width * fix & add tests
This commit is contained in:
+3
@@ -0,0 +1,3 @@
|
||||
.auth-panel__dropdown-provider {
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { User, PostInfo } from '../../common/types';
|
||||
const DefaultProps: Partial<Props> = {
|
||||
sort: '-score',
|
||||
providers: ['google', 'github'],
|
||||
provider: { name: null },
|
||||
postInfo: {
|
||||
read_only: false,
|
||||
url: 'https://example.com',
|
||||
@@ -42,6 +43,25 @@ describe('<AuthPanel />', () => {
|
||||
expect(providerLinks[1].textContent).toEqual('GitHub');
|
||||
});
|
||||
|
||||
it('should place selected provider first', () => {
|
||||
const element = <AuthPanel {...(DefaultProps as Props)} provider={{ name: 'github' }} user={null} />;
|
||||
|
||||
render(element, container);
|
||||
|
||||
const authPanelColumn = container.querySelectorAll('.auth-panel__column');
|
||||
|
||||
expect(authPanelColumn.length).toEqual(2);
|
||||
|
||||
const authForm = authPanelColumn[0];
|
||||
|
||||
expect(authForm.textContent).toEqual(expect.stringContaining('Sign in to comment using'));
|
||||
|
||||
const providerLinks = authForm.querySelectorAll('.auth-panel__pseudo-link');
|
||||
|
||||
expect(providerLinks[0].textContent).toEqual('GitHub');
|
||||
expect(providerLinks[1].textContent).toEqual('Google');
|
||||
});
|
||||
|
||||
it('should render login form with google and github provider for read-only post', () => {
|
||||
const element = (
|
||||
<AuthPanel
|
||||
|
||||
@@ -13,15 +13,18 @@ import { UserID } from './__user-id';
|
||||
import { AnonymousLoginForm } from './__anonymous-login-form';
|
||||
import { EmailLoginForm, EmailLoginFormConnected } from './__email-login-form';
|
||||
import { StoreState } from '@app/store';
|
||||
import { ProviderState } from '@app/store/provider/reducers';
|
||||
import debounce from '@app/utils/debounce';
|
||||
|
||||
export interface Props {
|
||||
user: User | null;
|
||||
hiddenUsers: StoreState['hiddenUsers'];
|
||||
providers: (AuthProvider['name'])[];
|
||||
sort: Sorting;
|
||||
isCommentsDisabled: boolean;
|
||||
theme: Theme;
|
||||
postInfo: PostInfo;
|
||||
providers: (AuthProvider['name'])[];
|
||||
provider: ProviderState;
|
||||
|
||||
onSortChange(s: Sorting): Promise<void>;
|
||||
onSignIn(p: AuthProvider): Promise<User | null>;
|
||||
@@ -35,6 +38,7 @@ export interface Props {
|
||||
interface State {
|
||||
isBlockedVisible: boolean;
|
||||
anonymousUsernameInputValue: string;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export class AuthPanel extends Component<Props, State> {
|
||||
@@ -46,6 +50,7 @@ export class AuthPanel extends Component<Props, State> {
|
||||
this.state = {
|
||||
isBlockedVisible: false,
|
||||
anonymousUsernameInputValue: 'anon',
|
||||
threshold: 3,
|
||||
};
|
||||
|
||||
this.toggleBlockedVisibility = this.toggleBlockedVisibility.bind(this);
|
||||
@@ -59,6 +64,23 @@ export class AuthPanel extends Component<Props, State> {
|
||||
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 && this.emailLoginRef.focus();
|
||||
}
|
||||
@@ -117,175 +139,248 @@ export class AuthPanel extends Component<Props, State> {
|
||||
this.onSignIn({ name: p } as AuthProvider);
|
||||
}
|
||||
|
||||
render(props: RenderableProps<Props>, { isBlockedVisible }: State) {
|
||||
const { user, providers = [], sort, isCommentsDisabled } = props;
|
||||
const sortArray = getSortArray(sort);
|
||||
const loggedIn = !!user;
|
||||
const signInMessage = props.postInfo.read_only ? 'Sign in using ' : 'Sign in to comment using ';
|
||||
renderAuthorized = () => {
|
||||
const { user, onSignOut } = this.props;
|
||||
if (!user) return null;
|
||||
|
||||
const isUserAnonymous = user && user.id.substr(0, 10) === 'anonymous_';
|
||||
const isSettingsLabelVisible =
|
||||
Object.keys(this.props.hiddenUsers).length > 0 || (user && user.admin) || this.state.isBlockedVisible;
|
||||
|
||||
return (
|
||||
<div className={b('auth-panel', {}, { theme: props.theme, loggedIn })}>
|
||||
{user && (
|
||||
<div className="auth-panel__column">
|
||||
You signed in as{' '}
|
||||
<Dropdown title={user.name} theme={this.props.theme}>
|
||||
<DropdownItem separator={!isUserAnonymous}>
|
||||
<UserID id={user.id} theme={this.props.theme} {...getHandleClickProps(this.toggleUserInfoVisibility)} />
|
||||
</DropdownItem>
|
||||
<div className="auth-panel__column">
|
||||
You signed in as{' '}
|
||||
<Dropdown title={user.name} theme={this.props.theme}>
|
||||
<DropdownItem separator={!isUserAnonymous}>
|
||||
<UserID id={user.id} theme={this.props.theme} {...getHandleClickProps(this.toggleUserInfoVisibility)} />
|
||||
</DropdownItem>
|
||||
|
||||
{!isUserAnonymous && (
|
||||
<DropdownItem>
|
||||
<Button
|
||||
kind="link"
|
||||
theme={this.props.theme}
|
||||
onClick={() => requestDeletion().then(() => props.onSignOut())}
|
||||
>
|
||||
Request my data removal
|
||||
</Button>
|
||||
</DropdownItem>
|
||||
)}
|
||||
</Dropdown>{' '}
|
||||
<Button
|
||||
className="auth-panel__sign-out"
|
||||
kind="link"
|
||||
theme={this.props.theme}
|
||||
onClick={() => props.onSignOut()}
|
||||
>
|
||||
Sign out?
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{IS_STORAGE_AVAILABLE && !loggedIn && (
|
||||
<div className="auth-panel__column">
|
||||
{signInMessage}
|
||||
{providers.map((provider, i) => {
|
||||
const comma = i === 0 ? '' : i === providers.length - 1 ? ' or ' : ', ';
|
||||
|
||||
if (provider === 'anonymous') {
|
||||
return (
|
||||
<span>
|
||||
{comma}{' '}
|
||||
<Dropdown
|
||||
title={PROVIDER_NAMES[provider]}
|
||||
titleClass="auth-panel__pseudo-link"
|
||||
theme={this.props.theme}
|
||||
>
|
||||
<DropdownItem>
|
||||
<AnonymousLoginForm
|
||||
onSubmit={this.handleAnonymousLoginFormSubmut}
|
||||
theme={this.props.theme}
|
||||
className="auth-panel__anonymous-login-form"
|
||||
/>
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === 'email') {
|
||||
return (
|
||||
<span>
|
||||
{comma}{' '}
|
||||
<Dropdown
|
||||
title={PROVIDER_NAMES[provider]}
|
||||
titleClass="auth-panel__pseudo-link"
|
||||
theme={this.props.theme}
|
||||
onTitleClick={this.onEmailTitleClick}
|
||||
>
|
||||
<DropdownItem>
|
||||
<EmailLoginFormConnected
|
||||
ref={ref => (this.emailLoginRef = ref ? ref.getWrappedInstance() : null)}
|
||||
onSignIn={this.onEmailSignIn}
|
||||
theme={this.props.theme}
|
||||
className="auth-panel__email-login-form"
|
||||
/>
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
{comma}
|
||||
<span
|
||||
className="auth-panel__pseudo-link"
|
||||
data-provider={provider}
|
||||
// eslint-disable-next-line @typescript-eslint/no-object-literal-type-assertion
|
||||
{...getHandleClickProps(this.handleOAuthLogin)}
|
||||
role="link"
|
||||
>
|
||||
{PROVIDER_NAMES[provider]}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!IS_STORAGE_AVAILABLE && IS_THIRD_PARTY && (
|
||||
<div className="auth-panel__column">
|
||||
Disable third-party cookies blocking to sign in or open comments in{' '}
|
||||
<a
|
||||
class="auth-panel__pseudo-link"
|
||||
href={`${window.location.origin}/web/comments.html${window.location.search}`}
|
||||
target="_blank"
|
||||
>
|
||||
new page
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!IS_STORAGE_AVAILABLE && !IS_THIRD_PARTY && (
|
||||
<div className="auth-panel__column">Allow cookies to sign in and comment</div>
|
||||
)}
|
||||
|
||||
<div className="auth-panel__column">
|
||||
{isSettingsLabelVisible && (
|
||||
<span
|
||||
className="auth-panel__pseudo-link auth-panel__admin-action"
|
||||
{...getHandleClickProps(() => this.toggleBlockedVisibility())}
|
||||
role="link"
|
||||
>
|
||||
{isBlockedVisible ? 'Hide' : 'Show'} settings
|
||||
</span>
|
||||
{!isUserAnonymous && (
|
||||
<DropdownItem>
|
||||
<Button kind="link" theme={this.props.theme} onClick={() => requestDeletion().then(onSignOut)}>
|
||||
Request my data removal
|
||||
</Button>
|
||||
</DropdownItem>
|
||||
)}
|
||||
</Dropdown>{' '}
|
||||
<Button className="auth-panel__sign-out" kind="link" theme={this.props.theme} onClick={onSignOut}>
|
||||
Sign out?
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderProvider = (provider: AuthProvider['name'], dropdown: boolean = false) => {
|
||||
if (provider === 'anonymous') {
|
||||
return (
|
||||
<Dropdown
|
||||
title={PROVIDER_NAMES['anonymous']}
|
||||
titleClass={`${dropdown ? 'auth-panel__dropdown-provider' : ''} auth-panel__pseudo-link`}
|
||||
theme={this.props.theme}
|
||||
>
|
||||
<DropdownItem>
|
||||
<AnonymousLoginForm
|
||||
onSubmit={this.handleAnonymousLoginFormSubmut}
|
||||
theme={this.props.theme}
|
||||
className="auth-panel__anonymous-login-form"
|
||||
/>
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
if (provider === 'email') {
|
||||
return (
|
||||
<Dropdown
|
||||
title={PROVIDER_NAMES['email']}
|
||||
titleClass={`${dropdown ? 'auth-panel__dropdown-provider' : ''} auth-panel__pseudo-link`}
|
||||
theme={this.props.theme}
|
||||
onTitleClick={this.onEmailTitleClick}
|
||||
>
|
||||
<DropdownItem>
|
||||
<EmailLoginFormConnected
|
||||
ref={ref => (this.emailLoginRef = ref ? ref.getWrappedInstance() : null)}
|
||||
onSignIn={this.onEmailSignIn}
|
||||
theme={this.props.theme}
|
||||
className="auth-panel__email-login-form"
|
||||
/>
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`${dropdown ? 'auth-panel__dropdown-provider' : ''} auth-panel__pseudo-link`}
|
||||
data-provider={provider}
|
||||
// eslint-disable-next-line @typescript-eslint/no-object-literal-type-assertion
|
||||
{...getHandleClickProps(this.handleOAuthLogin)}
|
||||
role="link"
|
||||
>
|
||||
{PROVIDER_NAMES[provider]}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
renderOther = (providers: (AuthProvider['name'])[]) => {
|
||||
return (
|
||||
<Dropdown
|
||||
title="Other"
|
||||
titleClass="auth-panel__pseudo-link"
|
||||
theme={this.props.theme}
|
||||
onTitleClick={this.onEmailTitleClick}
|
||||
>
|
||||
{providers.map(provider => (
|
||||
<DropdownItem>{this.renderProvider(provider, true)}</DropdownItem>
|
||||
))}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
renderUnauthorized = () => {
|
||||
const { user, providers = [], postInfo } = this.props;
|
||||
const { threshold } = this.state;
|
||||
if (user || !IS_STORAGE_AVAILABLE) return null;
|
||||
|
||||
const signInMessage = postInfo.read_only ? 'Sign in using ' : 'Sign in to comment using ';
|
||||
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) return providers;
|
||||
return [
|
||||
this.props.provider.name as typeof providers[0],
|
||||
...providers.slice(0, lastProviderIndex),
|
||||
...providers.slice(lastProviderIndex + 1),
|
||||
];
|
||||
})();
|
||||
|
||||
const isAboveThreshold = sortedProviders.length > threshold;
|
||||
|
||||
return (
|
||||
<div className="auth-panel__column">
|
||||
{signInMessage}
|
||||
{!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>
|
||||
);
|
||||
};
|
||||
|
||||
renderThirdPartyWarning = () => {
|
||||
if (IS_STORAGE_AVAILABLE || !IS_THIRD_PARTY) return null;
|
||||
return (
|
||||
<div className="auth-panel__column">
|
||||
Disable third-party cookies blocking to sign in or open comments in{' '}
|
||||
<a
|
||||
class="auth-panel__pseudo-link"
|
||||
href={`${window.location.origin}/web/comments.html${window.location.search}`}
|
||||
target="_blank"
|
||||
>
|
||||
new page
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderCookiesWarning = () => {
|
||||
if (IS_STORAGE_AVAILABLE || IS_THIRD_PARTY) return null;
|
||||
return <div className="auth-panel__column">Allow cookies to sign in and comment</div>;
|
||||
};
|
||||
|
||||
renderSettingsLabel = () => {
|
||||
return (
|
||||
<span
|
||||
className="auth-panel__pseudo-link auth-panel__admin-action"
|
||||
{...getHandleClickProps(() => this.toggleBlockedVisibility())}
|
||||
role="link"
|
||||
>
|
||||
{this.state.isBlockedVisible ? 'Hide' : 'Show'} settings
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
renderReadOnlySwitch = () => {
|
||||
const { isCommentsDisabled } = this.props;
|
||||
return (
|
||||
<span
|
||||
className="auth-panel__pseudo-link auth-panel__admin-action"
|
||||
{...getHandleClickProps(() => this.toggleCommentsAvailability())}
|
||||
role="link"
|
||||
>
|
||||
{isCommentsDisabled ? 'Enable' : 'Disable'} comments
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
renderSort = () => {
|
||||
const { sort } = this.props;
|
||||
const sortArray = getSortArray(sort);
|
||||
return (
|
||||
<span className="auth-panel__sort">
|
||||
Sort by{' '}
|
||||
<span className="auth-panel__select-label">
|
||||
{sortArray.find(x => 'selected' in x && x.selected!)!.label}
|
||||
<select className="auth-panel__select" onChange={this.onSortChange} onBlur={this.onSortChange}>
|
||||
{sortArray.map(sort => (
|
||||
<option value={sort.value} selected={sort.selected}>
|
||||
{sort.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
render(props: RenderableProps<Props>, { isBlockedVisible }: State) {
|
||||
const {
|
||||
user,
|
||||
postInfo: { read_only },
|
||||
theme,
|
||||
} = props;
|
||||
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()}
|
||||
{this.renderThirdPartyWarning()}
|
||||
{this.renderCookiesWarning()}
|
||||
<div className="auth-panel__column">
|
||||
{isSettingsLabelVisible && this.renderSettingsLabel()}
|
||||
|
||||
{isSettingsLabelVisible && ' • '}
|
||||
|
||||
{user && user.admin && (
|
||||
<span
|
||||
className="auth-panel__pseudo-link auth-panel__admin-action"
|
||||
{...getHandleClickProps(() => this.toggleCommentsAvailability())}
|
||||
role="link"
|
||||
>
|
||||
{isCommentsDisabled ? 'Enable' : 'Disable'} comments
|
||||
</span>
|
||||
)}
|
||||
{isAdmin && this.renderReadOnlySwitch()}
|
||||
|
||||
{user && user.admin && ' • '}
|
||||
{isAdmin && ' • '}
|
||||
|
||||
{!(user && user.admin) && props.postInfo.read_only && (
|
||||
<span className="auth-panel__readonly-label">Read-only</span>
|
||||
)}
|
||||
{!isAdmin && read_only && <span className="auth-panel__readonly-label">Read-only</span>}
|
||||
|
||||
<span className="auth-panel__sort">
|
||||
Sort by{' '}
|
||||
<span className="auth-panel__select-label">
|
||||
{sortArray.find(x => 'selected' in x && x.selected!)!.label}
|
||||
<select className="auth-panel__select" onChange={this.onSortChange} onBlur={this.onSortChange}>
|
||||
{sortArray.map(sort => (
|
||||
<option value={sort.value} selected={sort.selected}>
|
||||
{sort.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
</span>
|
||||
{this.renderSort()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,3 +17,5 @@ require('./_theme/_dark/auth-panel_theme_dark.scss');
|
||||
require('./_theme/_light/auth-panel_theme_light.scss');
|
||||
|
||||
require('./_logged-in/auth-panel_logged-in.scss');
|
||||
|
||||
require('./__dropdown-provider/auth-panel__dropdown-provider.scss');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.dropdown__item {
|
||||
a,
|
||||
button {
|
||||
& > a,
|
||||
& > button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.dropdown_active {
|
||||
.dropdown__content {
|
||||
& > .dropdown__content {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,25 +66,36 @@ export default class Dropdown extends Component<Props, State> {
|
||||
}
|
||||
|
||||
storedDocumentHeight: string | null = null;
|
||||
storedDocumentHeightSet: boolean = false;
|
||||
checkInterval: number | undefined = undefined;
|
||||
|
||||
__onOpen() {
|
||||
let firstPass = false;
|
||||
const isChildOfDropDown = (() => {
|
||||
if (!this.rootNode) return false;
|
||||
let parent = this.rootNode.parentElement!;
|
||||
while (parent !== document.body) {
|
||||
if (parent.classList.contains('dropdown')) return true;
|
||||
parent = parent.parentElement!;
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
if (isChildOfDropDown) return;
|
||||
|
||||
this.storedDocumentHeight = document.body.style.minHeight;
|
||||
this.storedDocumentHeightSet = true;
|
||||
|
||||
let prevDcBottom: number | null = null;
|
||||
|
||||
this.checkInterval = window.setInterval(() => {
|
||||
if (!this.rootNode || !this.state.isActive) return;
|
||||
const windowHeight = window.innerHeight;
|
||||
const dcBottom = (() => {
|
||||
const dc = this.rootNode.querySelector('.dropdown__content');
|
||||
const dc = Array.from(this.rootNode.children).find(c => c.classList.contains('dropdown__content'));
|
||||
if (!dc) return 0;
|
||||
const rect = dc.getBoundingClientRect();
|
||||
return Math.abs(rect.top) + rect.height;
|
||||
return window.scrollY + Math.abs(rect.top) + dc.scrollHeight + 10;
|
||||
})();
|
||||
if (prevDcBottom === null && dcBottom <= windowHeight) return;
|
||||
if (!firstPass) {
|
||||
firstPass = true;
|
||||
this.storedDocumentHeight = document.body.style.minHeight;
|
||||
}
|
||||
if (dcBottom !== prevDcBottom) {
|
||||
prevDcBottom = dcBottom;
|
||||
document.body.style.minHeight = dcBottom + 'px';
|
||||
@@ -94,7 +105,9 @@ export default class Dropdown extends Component<Props, State> {
|
||||
|
||||
__onClose() {
|
||||
window.clearInterval(this.checkInterval);
|
||||
document.body.style.minHeight = this.storedDocumentHeight;
|
||||
if (this.storedDocumentHeightSet) {
|
||||
document.body.style.minHeight = this.storedDocumentHeight;
|
||||
}
|
||||
}
|
||||
|
||||
async __adjustDropDownContent() {
|
||||
|
||||
@@ -50,6 +50,7 @@ import { Thread } from '@app/components/thread';
|
||||
import { uploadImage, getPreview } from '@app/common/api';
|
||||
import { isUserAnonymous } from '@app/utils/isUserAnonymous';
|
||||
import { bindActions } from '@app/utils/actionBinder';
|
||||
import { ProviderState } from '@app/store/provider/reducers';
|
||||
|
||||
const boundActions = bindActions({
|
||||
fetchComments,
|
||||
@@ -82,6 +83,7 @@ type Props = {
|
||||
isSettingsVisible: boolean;
|
||||
getPreview: typeof getPreview;
|
||||
uploadImage: typeof uploadImage;
|
||||
provider: ProviderState;
|
||||
} & typeof boundActions;
|
||||
|
||||
interface State {
|
||||
@@ -229,9 +231,10 @@ export class Root extends Component<Props, State> {
|
||||
user={this.props.user}
|
||||
hiddenUsers={this.props.hiddenUsers}
|
||||
sort={this.props.sort}
|
||||
providers={StaticStore.config.auth_providers}
|
||||
isCommentsDisabled={isCommentsDisabled}
|
||||
postInfo={this.props.info}
|
||||
providers={StaticStore.config.auth_providers}
|
||||
provider={this.props.provider}
|
||||
onSignIn={this.logIn}
|
||||
onSignOut={this.logOut}
|
||||
onBlockedUsersShow={this.onBlockedUsersShow}
|
||||
@@ -330,6 +333,7 @@ export const ConnectedRoot = connect(
|
||||
info: state.info,
|
||||
hiddenUsers: state.hiddenUsers,
|
||||
blockedUsers: state.bannedUsers,
|
||||
provider: state.provider,
|
||||
getPreview,
|
||||
uploadImage,
|
||||
}),
|
||||
|
||||
@@ -18,6 +18,8 @@ import { StaticStore } from '@app/common/static_store';
|
||||
import api from '@app/common/api';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { fetchHiddenUsers } from './store/user/actions';
|
||||
import { restoreProvider } from './store/provider/actions';
|
||||
import { restoreCollapsedThreads } from './store/thread/actions';
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
@@ -37,8 +39,13 @@ async function init(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const boundFetchHiddenUsers = bindActionCreators(fetchHiddenUsers, reduxStore.dispatch);
|
||||
boundFetchHiddenUsers();
|
||||
const boundActions = bindActionCreators(
|
||||
{ fetchHiddenUsers, restoreProvider, restoreCollapsedThreads },
|
||||
reduxStore.dispatch
|
||||
);
|
||||
boundActions.fetchHiddenUsers();
|
||||
boundActions.restoreProvider();
|
||||
boundActions.restoreCollapsedThreads();
|
||||
|
||||
const params = window.location.search
|
||||
.replace(/^\?/, '')
|
||||
|
||||
@@ -5,6 +5,7 @@ import { THEME_ACTIONS } from './theme/types';
|
||||
import { THREAD_ACTIONS } from './thread/types';
|
||||
import { USER_ACTIONS } from './user/types';
|
||||
import { USER_INFO_ACTIONS } from './user-info/types';
|
||||
import { PROVIDER_ACTIONS } from './provider/types';
|
||||
|
||||
/** Merged store actions */
|
||||
export type ACTIONS =
|
||||
@@ -14,4 +15,5 @@ export type ACTIONS =
|
||||
| THEME_ACTIONS
|
||||
| THREAD_ACTIONS
|
||||
| USER_ACTIONS
|
||||
| USER_INFO_ACTIONS;
|
||||
| USER_INFO_ACTIONS
|
||||
| PROVIDER_ACTIONS;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Node, Comment } from '@app/common/types';
|
||||
import { Node, Comment, CommentMode } from '@app/common/types';
|
||||
|
||||
import { StoreState } from '../index';
|
||||
import {
|
||||
COMMENTS_SET,
|
||||
COMMENTS_SET_ACTION,
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
COMMENT_MODE_SET_ACTION,
|
||||
} from './types';
|
||||
|
||||
export const comments = (state: StoreState['comments'] = [], action: COMMENTS_SET_ACTION): Node[] => {
|
||||
export const comments = (state: Node[] = [], action: COMMENTS_SET_ACTION): Node[] => {
|
||||
switch (action.type) {
|
||||
case COMMENTS_SET: {
|
||||
return action.comments;
|
||||
@@ -20,10 +19,12 @@ export const comments = (state: StoreState['comments'] = [], action: COMMENTS_SE
|
||||
}
|
||||
};
|
||||
|
||||
export type ActiveCommentState = null | { id: Comment['id']; state: CommentMode };
|
||||
|
||||
export const activeComment = (
|
||||
state: StoreState['activeComment'] = null,
|
||||
state: ActiveCommentState = null,
|
||||
action: COMMENT_MODE_SET_ACTION
|
||||
): StoreState['activeComment'] => {
|
||||
): ActiveCommentState => {
|
||||
switch (action.type) {
|
||||
case COMMENT_MODE_SET: {
|
||||
return action.mode;
|
||||
@@ -33,10 +34,7 @@ export const activeComment = (
|
||||
}
|
||||
};
|
||||
|
||||
export const pinnedComments = (
|
||||
state: StoreState['pinnedComments'] = [],
|
||||
action: PINNED_COMMENTS_SET_ACTION
|
||||
): Comment[] => {
|
||||
export const pinnedComments = (state: Comment[] = [], action: PINNED_COMMENTS_SET_ACTION): Comment[] => {
|
||||
switch (action.type) {
|
||||
case PINNED_COMMENTS_SET: {
|
||||
return action.comments;
|
||||
|
||||
@@ -1,43 +1,13 @@
|
||||
import { createStore, applyMiddleware, AnyAction, compose } from 'redux';
|
||||
import { combineReducers } from 'redux';
|
||||
import thunk, { ThunkAction, ThunkDispatch } from 'redux-thunk';
|
||||
import { Comment, User, PostInfo, Node, BlockedUser, Theme, Sorting, CommentMode } from '@app/common/types';
|
||||
|
||||
import storeReducers from './reducers';
|
||||
import { ACTIONS } from './actions';
|
||||
|
||||
export interface StoreState {
|
||||
/** Comments sort */
|
||||
sort: Sorting;
|
||||
/** Comments list */
|
||||
comments: Node[];
|
||||
/** List of pinned comments */
|
||||
pinnedComments: Comment[];
|
||||
/** Defines comment that is in reply or edit mode */
|
||||
activeComment: null | { id: Comment['id']; state: CommentMode };
|
||||
/** Logged in user */
|
||||
user: User | null;
|
||||
/** Remark's styling theme */
|
||||
theme: Theme;
|
||||
/** Current post information */
|
||||
info: PostInfo;
|
||||
/** List of banned users */
|
||||
bannedUsers: BlockedUser[];
|
||||
/** List of hidden users */
|
||||
hiddenUsers: { [id: string]: User };
|
||||
/** Whether list of blocked users should be visible */
|
||||
isSettingsVisible: boolean;
|
||||
/** Map of collapsed threads */
|
||||
collapsedThreads: {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
/** used in user comments widget */
|
||||
userComments?: {
|
||||
[key: string]: Comment[];
|
||||
};
|
||||
}
|
||||
const reducers = combineReducers(storeReducers);
|
||||
|
||||
export type StoreState = ReturnType<typeof reducers>;
|
||||
|
||||
const reducers = combineReducers<StoreState>(storeReducers);
|
||||
const middleware = applyMiddleware(thunk);
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { PROVIDER_UPDATE_ACTION, PROVIDER_UPDATE } from './types';
|
||||
import { StoreAction } from '..';
|
||||
import { setItem, getItem } from '@app/common/local-storage';
|
||||
|
||||
const PROVIDER_LOCALSTORAGE_KEY = '__remarkProvider';
|
||||
|
||||
/** saves last login provider from localstorage and put to store */
|
||||
export function updateProvider(payload: PROVIDER_UPDATE_ACTION['payload']): StoreAction<void, PROVIDER_UPDATE_ACTION> {
|
||||
return dispatch => {
|
||||
setItem(PROVIDER_LOCALSTORAGE_KEY, JSON.stringify(payload));
|
||||
dispatch({
|
||||
type: PROVIDER_UPDATE,
|
||||
payload,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** restores last login provider from localstorage and put to store */
|
||||
export function restoreProvider(): StoreAction<void, PROVIDER_UPDATE_ACTION> {
|
||||
return dispatch => {
|
||||
const payloadString = getItem(PROVIDER_LOCALSTORAGE_KEY);
|
||||
if (!payloadString) return;
|
||||
try {
|
||||
const payload = JSON.parse(payloadString);
|
||||
dispatch({
|
||||
type: PROVIDER_UPDATE,
|
||||
payload,
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import reducer from './reducers';
|
||||
import { PROVIDER_UPDATE } from './types';
|
||||
|
||||
describe('provider reducer', () => {
|
||||
it('should set name of provider', () => {
|
||||
const result = reducer.provider(
|
||||
{ name: null },
|
||||
{
|
||||
type: PROVIDER_UPDATE,
|
||||
payload: {
|
||||
name: 'something',
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(result).toStrictEqual({
|
||||
name: 'something',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PROVIDER_ACTIONS, PROVIDER_UPDATE } from './types';
|
||||
|
||||
export interface ProviderState {
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
function provider(state: ProviderState = { name: null }, action: PROVIDER_ACTIONS): ProviderState {
|
||||
switch (action.type) {
|
||||
case PROVIDER_UPDATE: {
|
||||
return { ...state, ...action.payload };
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default { provider };
|
||||
@@ -0,0 +1,9 @@
|
||||
export const PROVIDER_UPDATE = 'PROVIDER/UPDATE';
|
||||
export interface PROVIDER_UPDATE_ACTION {
|
||||
type: typeof PROVIDER_UPDATE;
|
||||
payload: {
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type PROVIDER_ACTIONS = PROVIDER_UPDATE_ACTION;
|
||||
@@ -5,6 +5,7 @@ import theme from './theme/reducers';
|
||||
import user from './user/reducers';
|
||||
import userInfo from './user-info/reducers';
|
||||
import thread from './thread/reducers';
|
||||
import provider from './provider/reducers';
|
||||
|
||||
/** Merged store reducers */
|
||||
export default {
|
||||
@@ -15,4 +16,5 @@ export default {
|
||||
...user,
|
||||
...userInfo,
|
||||
...thread,
|
||||
...provider,
|
||||
};
|
||||
|
||||
@@ -2,8 +2,13 @@ import { Comment } from '@app/common/types';
|
||||
import { siteId, url } from '@app/common/settings';
|
||||
|
||||
import { StoreAction } from '../index';
|
||||
import { THREAD_SET_COLLAPSE } from './types';
|
||||
import { saveCollapsedComments } from './utils';
|
||||
import { THREAD_SET_COLLAPSE, THREAD_RESTORE_COLLAPSE_ACTION, THREAD_RESTORE_COLLAPSE } from './types';
|
||||
import { saveCollapsedComments, getCollapsedComments } from './utils';
|
||||
|
||||
export const restoreCollapsedThreads = (): THREAD_RESTORE_COLLAPSE_ACTION => ({
|
||||
type: THREAD_RESTORE_COLLAPSE,
|
||||
ids: getCollapsedComments(),
|
||||
});
|
||||
|
||||
export const setCollapse = (id: Comment['id'], value: boolean): StoreAction<void> => (dispatch, getState) => {
|
||||
dispatch({
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
import { THREAD_GET_COLLAPSE_ACTION, THREAD_SET_COLLAPSE, THREAD_SET_COLLAPSE_ACTION } from './types';
|
||||
import { getCollapsedComments } from './utils';
|
||||
import { StoreState } from '../index';
|
||||
import { THREAD_SET_COLLAPSE, THREAD_ACTIONS, THREAD_RESTORE_COLLAPSE } from './types';
|
||||
|
||||
const collapsedCommentIds = getCollapsedComments();
|
||||
export interface CollapsedThreadsState {
|
||||
[key: string]: boolean;
|
||||
}
|
||||
|
||||
const initialState: StoreState['collapsedThreads'] = collapsedCommentIds.reduce(
|
||||
(acc: { [key: string]: boolean }, id) => {
|
||||
acc[id] = true;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
export const collapsedThreads = (
|
||||
state: StoreState['collapsedThreads'] = initialState,
|
||||
action: THREAD_GET_COLLAPSE_ACTION | THREAD_SET_COLLAPSE_ACTION
|
||||
): { [key: string]: boolean } => {
|
||||
export const collapsedThreads = (state: CollapsedThreadsState = {}, action: THREAD_ACTIONS): CollapsedThreadsState => {
|
||||
switch (action.type) {
|
||||
case THREAD_SET_COLLAPSE:
|
||||
case THREAD_RESTORE_COLLAPSE: {
|
||||
return action.ids.reduce<CollapsedThreadsState>((acc, id) => {
|
||||
acc[id] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
case THREAD_SET_COLLAPSE: {
|
||||
return {
|
||||
...state,
|
||||
[action.id]: action.collapsed,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Comment } from '@app/common/types';
|
||||
|
||||
export const THREAD_GET_COLLAPSE = 'THREAD/COLLAPSE_GET';
|
||||
export interface THREAD_GET_COLLAPSE_ACTION {
|
||||
type: typeof THREAD_GET_COLLAPSE;
|
||||
export const THREAD_RESTORE_COLLAPSE = 'THREAD/COLLAPSE_RESTORE';
|
||||
export interface THREAD_RESTORE_COLLAPSE_ACTION {
|
||||
type: typeof THREAD_RESTORE_COLLAPSE;
|
||||
ids: (Comment['id'])[];
|
||||
}
|
||||
|
||||
export const THREAD_SET_COLLAPSE = 'THREAD/COLLAPSE_SET';
|
||||
@@ -12,4 +13,4 @@ export interface THREAD_SET_COLLAPSE_ACTION {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export type THREAD_ACTIONS = THREAD_GET_COLLAPSE_ACTION | THREAD_SET_COLLAPSE_ACTION;
|
||||
export type THREAD_ACTIONS = THREAD_RESTORE_COLLAPSE_ACTION | THREAD_SET_COLLAPSE_ACTION;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Comment } from '@app/common/types';
|
||||
|
||||
import { StoreState } from '../index';
|
||||
import { USER_INFO_SET, USER_INFO_ACTIONS } from './types';
|
||||
|
||||
export const userComments = (
|
||||
state: StoreState['userComments'] = {},
|
||||
action: USER_INFO_ACTIONS
|
||||
): { [key: string]: Comment[] } => {
|
||||
export interface UserCommentsState {
|
||||
[key: string]: Comment[];
|
||||
}
|
||||
|
||||
export const userComments = (state: UserCommentsState = {}, action: USER_INFO_ACTIONS): UserCommentsState => {
|
||||
switch (action.type) {
|
||||
case USER_INFO_SET: {
|
||||
return {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { setUserVerified as uSetUserVerified, filterTree, mapTree } from '../com
|
||||
import { IS_STORAGE_AVAILABLE, LS_HIDDEN_USERS_KEY } from '@app/common/constants';
|
||||
import { getItem } from '@app/common/local-storage';
|
||||
import { Dispatch } from 'redux';
|
||||
import { updateProvider } from '../provider/actions';
|
||||
|
||||
export const fetchUser = (): StoreAction<Promise<User | null>> => async dispatch => {
|
||||
const user = await api.getUser();
|
||||
@@ -33,6 +34,7 @@ export const fetchUser = (): StoreAction<Promise<User | null>> => async dispatch
|
||||
|
||||
export const logIn = (provider: AuthProvider): StoreAction<Promise<User | null>> => async dispatch => {
|
||||
const user = await api.logIn(provider);
|
||||
dispatch(updateProvider({ name: provider.name }));
|
||||
dispatch({
|
||||
type: USER_SET,
|
||||
user,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { User, BlockedUser } from '@app/common/types';
|
||||
|
||||
import { StoreState } from '../index';
|
||||
import {
|
||||
USER_SET,
|
||||
USER_BAN,
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
USER_UNHIDE,
|
||||
} from './types';
|
||||
|
||||
export const user = (state: StoreState['user'] = null, action: USER_ACTIONS): User | null => {
|
||||
export const user = (state: User | null = null, action: USER_ACTIONS): User | null => {
|
||||
switch (action.type) {
|
||||
case USER_SET: {
|
||||
return action.user;
|
||||
@@ -24,7 +23,7 @@ export const user = (state: StoreState['user'] = null, action: USER_ACTIONS): Us
|
||||
}
|
||||
};
|
||||
|
||||
export const bannedUsers = (state: StoreState['bannedUsers'] = [], action: USER_ACTIONS): BlockedUser[] => {
|
||||
export const bannedUsers = (state: BlockedUser[] = [], action: USER_ACTIONS): BlockedUser[] => {
|
||||
switch (action.type) {
|
||||
case USER_BANLIST_SET: {
|
||||
return action.list;
|
||||
@@ -47,7 +46,7 @@ export const bannedUsers = (state: StoreState['bannedUsers'] = [], action: USER_
|
||||
}
|
||||
};
|
||||
|
||||
export const hiddenUsers = (state: StoreState['hiddenUsers'] = {}, action: USER_ACTIONS): StoreState['hiddenUsers'] => {
|
||||
export const hiddenUsers = (state: { [id: string]: User } = {}, action: USER_ACTIONS): { [id: string]: User } => {
|
||||
switch (action.type) {
|
||||
case USER_HIDELIST_SET: {
|
||||
return action.payload;
|
||||
@@ -66,10 +65,7 @@ export const hiddenUsers = (state: StoreState['hiddenUsers'] = {}, action: USER_
|
||||
}
|
||||
};
|
||||
|
||||
export const isSettingsVisible = (
|
||||
state: StoreState['isSettingsVisible'] = false,
|
||||
action: SETTINGS_VISIBLE_SET_ACTION
|
||||
): boolean => {
|
||||
export const isSettingsVisible = (state: boolean = false, action: SETTINGS_VISIBLE_SET_ACTION): boolean => {
|
||||
switch (action.type) {
|
||||
case SETTINGS_VISIBLE_SET: {
|
||||
return action.state;
|
||||
|
||||
Reference in New Issue
Block a user