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:
Misha Vyrtsev
2019-07-25 22:54:59 -05:00
committed by Umputun
parent eb3589869d
commit 3b4628d0bd
23 changed files with 444 additions and 252 deletions
@@ -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
+254 -159
View File
@@ -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;
}
}
+21 -8
View File
@@ -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() {
+5 -1
View File
@@ -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,
}),
+9 -2
View File
@@ -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(/^\?/, '')
+3 -1
View File
@@ -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;
+7 -9
View File
@@ -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;
+3 -33
View File
@@ -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);
/**
+31
View File
@@ -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',
});
});
});
+17
View File
@@ -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 };
+9
View File
@@ -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;
+2
View File
@@ -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,
};
+7 -2
View File
@@ -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({
+13 -17
View File
@@ -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;
}
+5 -4
View File
@@ -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;
+5 -5
View File
@@ -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 {
+2
View File
@@ -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,
+4 -8
View File
@@ -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;