From 3b4628d0bd1700acd2cef2bc019071e8f7451233 Mon Sep 17 00:00:00 2001 From: Misha Vyrtsev Date: Fri, 26 Jul 2019 06:54:59 +0300 Subject: [PATCH] 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 --- .../auth-panel__dropdown-provider.scss | 3 + .../components/auth-panel/auth-panel.test.tsx | 20 + .../app/components/auth-panel/auth-panel.tsx | 413 +++++++++++------- frontend/app/components/auth-panel/index.ts | 2 + .../dropdown/__item/dropdown__item.scss | 4 +- .../dropdown/_active/dropdown_active.scss | 2 +- frontend/app/components/dropdown/dropdown.tsx | 29 +- frontend/app/components/root/root.tsx | 6 +- frontend/app/remark.tsx | 11 +- frontend/app/store/actions.ts | 4 +- frontend/app/store/comments/reducers.ts | 16 +- frontend/app/store/index.ts | 36 +- frontend/app/store/provider/actions.ts | 31 ++ frontend/app/store/provider/reducers.test.ts | 19 + frontend/app/store/provider/reducers.ts | 17 + frontend/app/store/provider/types.ts | 9 + frontend/app/store/reducers.ts | 2 + frontend/app/store/thread/actions.ts | 9 +- frontend/app/store/thread/reducers.ts | 30 +- frontend/app/store/thread/types.ts | 9 +- frontend/app/store/user-info/reducers.ts | 10 +- frontend/app/store/user/actions.ts | 2 + frontend/app/store/user/reducers.ts | 12 +- 23 files changed, 444 insertions(+), 252 deletions(-) create mode 100644 frontend/app/components/auth-panel/__dropdown-provider/auth-panel__dropdown-provider.scss create mode 100644 frontend/app/store/provider/actions.ts create mode 100644 frontend/app/store/provider/reducers.test.ts create mode 100644 frontend/app/store/provider/reducers.ts create mode 100644 frontend/app/store/provider/types.ts diff --git a/frontend/app/components/auth-panel/__dropdown-provider/auth-panel__dropdown-provider.scss b/frontend/app/components/auth-panel/__dropdown-provider/auth-panel__dropdown-provider.scss new file mode 100644 index 00000000..599ec6af --- /dev/null +++ b/frontend/app/components/auth-panel/__dropdown-provider/auth-panel__dropdown-provider.scss @@ -0,0 +1,3 @@ +.auth-panel__dropdown-provider { + padding: 0.2rem 0.4rem; +} diff --git a/frontend/app/components/auth-panel/auth-panel.test.tsx b/frontend/app/components/auth-panel/auth-panel.test.tsx index 6065a5fc..001f33c7 100644 --- a/frontend/app/components/auth-panel/auth-panel.test.tsx +++ b/frontend/app/components/auth-panel/auth-panel.test.tsx @@ -7,6 +7,7 @@ import { User, PostInfo } from '../../common/types'; const DefaultProps: Partial = { sort: '-score', providers: ['google', 'github'], + provider: { name: null }, postInfo: { read_only: false, url: 'https://example.com', @@ -42,6 +43,25 @@ describe('', () => { expect(providerLinks[1].textContent).toEqual('GitHub'); }); + it('should place selected provider first', () => { + const element = ; + + 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 = ( ; onSignIn(p: AuthProvider): Promise; @@ -35,6 +38,7 @@ export interface Props { interface State { isBlockedVisible: boolean; anonymousUsernameInputValue: string; + threshold: number; } export class AuthPanel extends Component { @@ -46,6 +50,7 @@ export class AuthPanel extends Component { this.state = { isBlockedVisible: false, anonymousUsernameInputValue: 'anon', + threshold: 3, }; this.toggleBlockedVisibility = this.toggleBlockedVisibility.bind(this); @@ -59,6 +64,23 @@ export class AuthPanel extends Component { 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 { this.onSignIn({ name: p } as AuthProvider); } - render(props: RenderableProps, { 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 ( -
- {user && ( -
- You signed in as{' '} - - - - +
+ You signed in as{' '} + + + + - {!isUserAnonymous && ( - - - - )} - {' '} - -
- )} - - {IS_STORAGE_AVAILABLE && !loggedIn && ( -
- {signInMessage} - {providers.map((provider, i) => { - const comma = i === 0 ? '' : i === providers.length - 1 ? ' or ' : ', '; - - if (provider === 'anonymous') { - return ( - - {comma}{' '} - - - - - - - ); - } - - if (provider === 'email') { - return ( - - {comma}{' '} - - - (this.emailLoginRef = ref ? ref.getWrappedInstance() : null)} - onSignIn={this.onEmailSignIn} - theme={this.props.theme} - className="auth-panel__email-login-form" - /> - - - - ); - } - - return ( - - {comma} - - {PROVIDER_NAMES[provider]} - - - ); - })} -
- )} - - {!IS_STORAGE_AVAILABLE && IS_THIRD_PARTY && ( -
- Disable third-party cookies blocking to sign in or open comments in{' '} - - new page - -
- )} - - {!IS_STORAGE_AVAILABLE && !IS_THIRD_PARTY && ( -
Allow cookies to sign in and comment
- )} - -
- {isSettingsLabelVisible && ( - this.toggleBlockedVisibility())} - role="link" - > - {isBlockedVisible ? 'Hide' : 'Show'} settings - + {!isUserAnonymous && ( + + + )} + {' '} + +
+ ); + }; + + renderProvider = (provider: AuthProvider['name'], dropdown: boolean = false) => { + if (provider === 'anonymous') { + return ( + + + + + + ); + } + if (provider === 'email') { + return ( + + + (this.emailLoginRef = ref ? ref.getWrappedInstance() : null)} + onSignIn={this.onEmailSignIn} + theme={this.props.theme} + className="auth-panel__email-login-form" + /> + + + ); + } + + return ( + + {PROVIDER_NAMES[provider]} + + ); + }; + + renderOther = (providers: (AuthProvider['name'])[]) => { + return ( + + {providers.map(provider => ( + {this.renderProvider(provider, true)} + ))} + + ); + }; + + 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 ( +
+ {signInMessage} + {!isAboveThreshold && + sortedProviders.map((provider, i) => { + const comma = i === 0 ? '' : i === sortedProviders.length - 1 ? ' or ' : ', '; + + return ( + + {comma} + {this.renderProvider(provider)} + + ); + })} + {isAboveThreshold && + sortedProviders.slice(0, threshold - 1).map((provider, i) => { + const comma = i === 0 ? '' : ', '; + + return ( + + {comma} + {this.renderProvider(provider)} + + ); + })} + {isAboveThreshold && ( + + {' or '} + {this.renderOther(sortedProviders.slice(threshold - 1))} + + )} +
+ ); + }; + + renderThirdPartyWarning = () => { + if (IS_STORAGE_AVAILABLE || !IS_THIRD_PARTY) return null; + return ( +
+ Disable third-party cookies blocking to sign in or open comments in{' '} + + new page + +
+ ); + }; + + renderCookiesWarning = () => { + if (IS_STORAGE_AVAILABLE || IS_THIRD_PARTY) return null; + return
Allow cookies to sign in and comment
; + }; + + renderSettingsLabel = () => { + return ( + this.toggleBlockedVisibility())} + role="link" + > + {this.state.isBlockedVisible ? 'Hide' : 'Show'} settings + + ); + }; + + renderReadOnlySwitch = () => { + const { isCommentsDisabled } = this.props; + return ( + this.toggleCommentsAvailability())} + role="link" + > + {isCommentsDisabled ? 'Enable' : 'Disable'} comments + + ); + }; + + renderSort = () => { + const { sort } = this.props; + const sortArray = getSortArray(sort); + return ( + + Sort by{' '} + + {sortArray.find(x => 'selected' in x && x.selected!)!.label} + + + + ); + }; + + render(props: RenderableProps, { 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 ( +
+ {this.renderAuthorized()} + {this.renderUnauthorized()} + {this.renderThirdPartyWarning()} + {this.renderCookiesWarning()} +
+ {isSettingsLabelVisible && this.renderSettingsLabel()} {isSettingsLabelVisible && ' • '} - {user && user.admin && ( - this.toggleCommentsAvailability())} - role="link" - > - {isCommentsDisabled ? 'Enable' : 'Disable'} comments - - )} + {isAdmin && this.renderReadOnlySwitch()} - {user && user.admin && ' • '} + {isAdmin && ' • '} - {!(user && user.admin) && props.postInfo.read_only && ( - Read-only - )} + {!isAdmin && read_only && Read-only} - - Sort by{' '} - - {sortArray.find(x => 'selected' in x && x.selected!)!.label} - - - + {this.renderSort()}
); diff --git a/frontend/app/components/auth-panel/index.ts b/frontend/app/components/auth-panel/index.ts index e3fb738d..08bc6383 100644 --- a/frontend/app/components/auth-panel/index.ts +++ b/frontend/app/components/auth-panel/index.ts @@ -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'); diff --git a/frontend/app/components/dropdown/__item/dropdown__item.scss b/frontend/app/components/dropdown/__item/dropdown__item.scss index 7ad17863..1f1a37ba 100644 --- a/frontend/app/components/dropdown/__item/dropdown__item.scss +++ b/frontend/app/components/dropdown/__item/dropdown__item.scss @@ -1,6 +1,6 @@ .dropdown__item { - a, - button { + & > a, + & > button { display: block; width: 100%; text-align: left; diff --git a/frontend/app/components/dropdown/_active/dropdown_active.scss b/frontend/app/components/dropdown/_active/dropdown_active.scss index 5a3e604b..3316d0eb 100644 --- a/frontend/app/components/dropdown/_active/dropdown_active.scss +++ b/frontend/app/components/dropdown/_active/dropdown_active.scss @@ -1,5 +1,5 @@ .dropdown_active { - .dropdown__content { + & > .dropdown__content { display: block; } } diff --git a/frontend/app/components/dropdown/dropdown.tsx b/frontend/app/components/dropdown/dropdown.tsx index 10098215..b4ffdda0 100644 --- a/frontend/app/components/dropdown/dropdown.tsx +++ b/frontend/app/components/dropdown/dropdown.tsx @@ -66,25 +66,36 @@ export default class Dropdown extends Component { } 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 { __onClose() { window.clearInterval(this.checkInterval); - document.body.style.minHeight = this.storedDocumentHeight; + if (this.storedDocumentHeightSet) { + document.body.style.minHeight = this.storedDocumentHeight; + } } async __adjustDropDownContent() { diff --git a/frontend/app/components/root/root.tsx b/frontend/app/components/root/root.tsx index ce5ba242..84845fa4 100644 --- a/frontend/app/components/root/root.tsx +++ b/frontend/app/components/root/root.tsx @@ -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 { 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, }), diff --git a/frontend/app/remark.tsx b/frontend/app/remark.tsx index 9eaa17e9..65ba48ae 100644 --- a/frontend/app/remark.tsx +++ b/frontend/app/remark.tsx @@ -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 { 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(/^\?/, '') diff --git a/frontend/app/store/actions.ts b/frontend/app/store/actions.ts index f95b2821..dbfcfdd9 100644 --- a/frontend/app/store/actions.ts +++ b/frontend/app/store/actions.ts @@ -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; diff --git a/frontend/app/store/comments/reducers.ts b/frontend/app/store/comments/reducers.ts index 7f33d525..be632636 100644 --- a/frontend/app/store/comments/reducers.ts +++ b/frontend/app/store/comments/reducers.ts @@ -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; diff --git a/frontend/app/store/index.ts b/frontend/app/store/index.ts index 3800808a..706f1767 100644 --- a/frontend/app/store/index.ts +++ b/frontend/app/store/index.ts @@ -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; -const reducers = combineReducers(storeReducers); const middleware = applyMiddleware(thunk); /** diff --git a/frontend/app/store/provider/actions.ts b/frontend/app/store/provider/actions.ts new file mode 100644 index 00000000..efd2dc28 --- /dev/null +++ b/frontend/app/store/provider/actions.ts @@ -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 { + 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 { + return dispatch => { + const payloadString = getItem(PROVIDER_LOCALSTORAGE_KEY); + if (!payloadString) return; + try { + const payload = JSON.parse(payloadString); + dispatch({ + type: PROVIDER_UPDATE, + payload, + }); + } catch {} + }; +} diff --git a/frontend/app/store/provider/reducers.test.ts b/frontend/app/store/provider/reducers.test.ts new file mode 100644 index 00000000..67238d04 --- /dev/null +++ b/frontend/app/store/provider/reducers.test.ts @@ -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', + }); + }); +}); diff --git a/frontend/app/store/provider/reducers.ts b/frontend/app/store/provider/reducers.ts new file mode 100644 index 00000000..997f1bd6 --- /dev/null +++ b/frontend/app/store/provider/reducers.ts @@ -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 }; diff --git a/frontend/app/store/provider/types.ts b/frontend/app/store/provider/types.ts new file mode 100644 index 00000000..513cc09a --- /dev/null +++ b/frontend/app/store/provider/types.ts @@ -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; diff --git a/frontend/app/store/reducers.ts b/frontend/app/store/reducers.ts index 2c7a30db..a3e2f2a6 100644 --- a/frontend/app/store/reducers.ts +++ b/frontend/app/store/reducers.ts @@ -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, }; diff --git a/frontend/app/store/thread/actions.ts b/frontend/app/store/thread/actions.ts index 6faa5165..e55e072f 100644 --- a/frontend/app/store/thread/actions.ts +++ b/frontend/app/store/thread/actions.ts @@ -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 => (dispatch, getState) => { dispatch({ diff --git a/frontend/app/store/thread/reducers.ts b/frontend/app/store/thread/reducers.ts index 7718275b..f21d39e1 100644 --- a/frontend/app/store/thread/reducers.ts +++ b/frontend/app/store/thread/reducers.ts @@ -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((acc, id) => { + acc[id] = true; + return acc; + }, {}); + } + case THREAD_SET_COLLAPSE: { return { ...state, [action.id]: action.collapsed, }; + } default: return state; } diff --git a/frontend/app/store/thread/types.ts b/frontend/app/store/thread/types.ts index c5afd40e..d4ca8424 100644 --- a/frontend/app/store/thread/types.ts +++ b/frontend/app/store/thread/types.ts @@ -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; diff --git a/frontend/app/store/user-info/reducers.ts b/frontend/app/store/user-info/reducers.ts index fffd506d..8995592d 100644 --- a/frontend/app/store/user-info/reducers.ts +++ b/frontend/app/store/user-info/reducers.ts @@ -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 { diff --git a/frontend/app/store/user/actions.ts b/frontend/app/store/user/actions.ts index 834b968c..e0906fe1 100644 --- a/frontend/app/store/user/actions.ts +++ b/frontend/app/store/user/actions.ts @@ -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> => async dispatch => { const user = await api.getUser(); @@ -33,6 +34,7 @@ export const fetchUser = (): StoreAction> => async dispatch export const logIn = (provider: AuthProvider): StoreAction> => async dispatch => { const user = await api.logIn(provider); + dispatch(updateProvider({ name: provider.name })); dispatch({ type: USER_SET, user, diff --git a/frontend/app/store/user/reducers.ts b/frontend/app/store/user/reducers.ts index fe3affff..7a133a09 100644 --- a/frontend/app/store/user/reducers.ts +++ b/frontend/app/store/user/reducers.ts @@ -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;