From a302bdbe5d8446933f01a67f89d0467fb500874f Mon Sep 17 00:00:00 2001 From: Misha Vyrtsev Date: Tue, 11 Jun 2019 03:20:39 +0300 Subject: [PATCH] Hide user comments (#334) * add ability to hide user via localstorage * remove closures to avoid reconcilation * add actionBinder utilities * add user hide/show feature * remove unused type * fix css for settings user id * remove pointless confirmation for hide/show user in settings * add redux-dev-tools support --- frontend/app/common/constants.ts | 3 + .../components/auth-panel/auth-panel.test.tsx | 36 ++- .../app/components/auth-panel/auth-panel.tsx | 10 +- ...ocked-users__list-item_view_invisible.scss | 6 - .../__list-item/blocked-users__list-item.scss | 11 - .../__list/blocked-users__list.scss | 3 - .../__username/blocked-users__username.scss | 3 - .../_dark/blocked-users_theme_dark.scss | 11 - .../_light/blocked-users_theme_light.scss | 11 - .../blocked-users/blocked-users.tsx | 103 ------ .../app/components/blocked-users/index.ts | 17 - .../app/components/comment/comment.test.tsx | 18 +- frontend/app/components/comment/comment.tsx | 293 ++++++++++-------- .../components/comment/connected-comment.ts | 62 ++-- frontend/app/components/root/root.tsx | 121 ++++---- .../__action/settings__action.scss} | 6 +- .../settings/__dimmed/settings__dimmed.scss | 3 + .../__invisible/settings__invisible.scss | 3 + .../settings/__list/settings__list.scss | 21 ++ .../settings/__section/settings__section.scss | 3 + .../settings/__user-id/settings__user-id.scss | 5 + .../__username/settings__username.scss | 3 + .../_theme/_dark/settings_theme_dark.scss | 11 + .../_theme/_light/settings_theme_light.scss | 11 + frontend/app/components/settings/index.ts | 16 + .../settings.scss} | 2 +- frontend/app/components/settings/settings.tsx | 178 +++++++++++ frontend/app/components/thread/thread.tsx | 2 +- frontend/app/remark.tsx | 7 +- frontend/app/store/comments/actions.ts | 6 +- frontend/app/store/comments/utils.ts | 21 ++ frontend/app/store/index.ts | 22 +- frontend/app/store/reducers.ts | 4 +- frontend/app/store/user/actions.ts | 55 +++- frontend/app/store/user/reducers.ts | 36 ++- frontend/app/store/user/types.ts | 35 ++- frontend/app/utils/actionBinder.ts | 23 ++ frontend/app/utils/ttl-to-time.ts | 39 +-- 38 files changed, 754 insertions(+), 466 deletions(-) delete mode 100644 frontend/app/components/blocked-users/__list-item/_view/_invisible/blocked-users__list-item_view_invisible.scss delete mode 100644 frontend/app/components/blocked-users/__list-item/blocked-users__list-item.scss delete mode 100644 frontend/app/components/blocked-users/__list/blocked-users__list.scss delete mode 100644 frontend/app/components/blocked-users/__username/blocked-users__username.scss delete mode 100644 frontend/app/components/blocked-users/_theme/_dark/blocked-users_theme_dark.scss delete mode 100644 frontend/app/components/blocked-users/_theme/_light/blocked-users_theme_light.scss delete mode 100644 frontend/app/components/blocked-users/blocked-users.tsx delete mode 100644 frontend/app/components/blocked-users/index.ts rename frontend/app/components/{blocked-users/__action/blocked-users__action.scss => settings/__action/settings__action.scss} (70%) create mode 100644 frontend/app/components/settings/__dimmed/settings__dimmed.scss create mode 100644 frontend/app/components/settings/__invisible/settings__invisible.scss create mode 100644 frontend/app/components/settings/__list/settings__list.scss create mode 100644 frontend/app/components/settings/__section/settings__section.scss create mode 100644 frontend/app/components/settings/__user-id/settings__user-id.scss create mode 100644 frontend/app/components/settings/__username/settings__username.scss create mode 100644 frontend/app/components/settings/_theme/_dark/settings_theme_dark.scss create mode 100644 frontend/app/components/settings/_theme/_light/settings_theme_light.scss create mode 100644 frontend/app/components/settings/index.ts rename frontend/app/components/{blocked-users/blocked-users.scss => settings/settings.scss} (55%) create mode 100644 frontend/app/components/settings/settings.tsx create mode 100644 frontend/app/utils/actionBinder.ts diff --git a/frontend/app/common/constants.ts b/frontend/app/common/constants.ts index e95482f7..217cbd27 100644 --- a/frontend/app/common/constants.ts +++ b/frontend/app/common/constants.ts @@ -27,6 +27,9 @@ export const PROVIDER_NAMES: { [P in AuthProvider['name']]: string } = { /** locastorage key for collapsed comments */ export const LS_COLLAPSE_KEY = '__remarkCollapsed'; +/** locastorage key for hidden users */ +export const LS_HIDDEN_USERS_KEY = '__remarkHiddenUsers'; + /** cookie key under which sort preference resides */ export const COOKIE_SORT_KEY = 'remarkSort'; diff --git a/frontend/app/components/auth-panel/auth-panel.test.tsx b/frontend/app/components/auth-panel/auth-panel.test.tsx index 221dab82..92e4f53d 100644 --- a/frontend/app/components/auth-panel/auth-panel.test.tsx +++ b/frontend/app/components/auth-panel/auth-panel.test.tsx @@ -12,6 +12,7 @@ const DefaultProps: Partial = { url: 'https://example.com', count: 3, }, + hiddenUsers: {}, }; describe('', () => { @@ -65,6 +66,39 @@ describe('', () => { expect(providerLinks[0].textContent).toEqual('Google'); expect(providerLinks[1].textContent).toEqual('GitHub'); }); + + it('should not render settings if there is no hidden users', () => { + const element = ( + + ); + + render(element, container); + + const adminAction = container.querySelector('.auth-panel__admin-action')!; + + expect(adminAction).toBe(null); + }); + + it('should render settings if there is some hidden users', () => { + const element = ( + + ); + + render(element, container); + + const adminAction = container.querySelector('.auth-panel__admin-action')!; + + expect(adminAction.textContent).toEqual('Show settings'); + }); }); describe('For authorized user', () => { let container: HTMLElement; @@ -101,7 +135,7 @@ describe('', () => { const adminAction = container.querySelector('.auth-panel__admin-action')!; - expect(adminAction.textContent).toEqual('Show blocked users'); + expect(adminAction.textContent).toEqual('Show settings'); }); }); }); diff --git a/frontend/app/components/auth-panel/auth-panel.tsx b/frontend/app/components/auth-panel/auth-panel.tsx index c0c21498..0e8b1e39 100644 --- a/frontend/app/components/auth-panel/auth-panel.tsx +++ b/frontend/app/components/auth-panel/auth-panel.tsx @@ -11,9 +11,11 @@ import Dropdown, { DropdownItem } from '@app/components/dropdown'; import { Button } from '@app/components/button'; import { UserID } from './__user-id'; import { AnonymousLoginForm } from './__anonymous-login-form'; +import { StoreState } from '@app/store'; export interface Props { user: User | null; + hiddenUsers: StoreState['hiddenUsers']; providers: (AuthProvider['name'])[]; sort: Sorting; isCommentsDisabled: boolean; @@ -108,6 +110,8 @@ export class AuthPanel extends Component { const loggedIn = !!user; const signInMessage = props.postInfo.read_only ? 'Sign in using ' : 'Sign in to comment using '; 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 (
@@ -205,17 +209,17 @@ export class AuthPanel extends Component { )}
- {user && user.admin && ( + {isSettingsLabelVisible && ( this.toggleBlockedVisibility())} role="link" > - {isBlockedVisible ? 'Hide' : 'Show'} blocked users + {isBlockedVisible ? 'Hide' : 'Show'} settings )} - {user && user.admin && ' • '} + {isSettingsLabelVisible && ' • '} {user && user.admin && ( ; - unblockUser(id: User['id']): Promise; - onUnblockSomeone(): void; -} - -interface State { - /** - * cached copy so we can - * reapply block on unblocked user - */ - users: BlockedUser[]; - unblockedUsers: (User['id'])[]; -} - -export default class BlockedUsers extends Component { - constructor(props: Props) { - super(props); - - this.state = { - users: props.users.slice(), - unblockedUsers: [], - }; - } - - block(user: BlockedUser) { - if (confirm(`Do you want to block ${user.name}?`)) { - this.setState({ - unblockedUsers: this.state.unblockedUsers.filter(x => x !== user.id), - }); - this.props.blockUser(user.id, user.name, 'permanently'); - } - } - - unblock(user: BlockedUser) { - if (confirm(`Do you want to unblock ${user.name}?`)) { - this.setState({ unblockedUsers: this.state.unblockedUsers.concat([user.id]) }); - this.props.unblockUser(user.id); - this.props.onUnblockSomeone(); - } - } - - render({ theme }: RenderableProps, { users, unblockedUsers }: State) { - return ( -
- {!users.length &&

There are no blocked users.

} - - {!!users.length &&

List of blocked users:

} - - {!!users.length && ( -
    - {users.map(user => { - const isUserUnblocked = unblockedUsers.includes(user.id); - - return ( -
  • - {user.name}{' '} - ({user.id}) - {formatTime(new Date(user.time))} - {isUserUnblocked && ( - this.block(user))} className="blocked-users__action"> - block - - )} - {!isUserUnblocked && ( - this.unblock(user))} className="blocked-users__action"> - unblock - - )} -
  • - ); - })} -
- )} -
- ); - } -} - -const currentYear = new Date().getFullYear(); - -function formatTime(time: Date): string { - // let's assume that if block ttl is more than 50 years then user blocked permanently - if (time.getFullYear() - currentYear >= 50) return 'permanently'; - - // 'ru-RU' adds a dot as a separator - const date = time.toLocaleDateString(['ru-RU'], { day: '2-digit', month: '2-digit', year: '2-digit' }); - - // do it manually because Intl API doesn't add leading zeros to hours; idk why - const hours = `0${time.getHours()}`.slice(-2); - const mins = `0${time.getMinutes()}`.slice(-2); - - return `until ${date} at ${hours}:${mins}`; -} diff --git a/frontend/app/components/blocked-users/index.ts b/frontend/app/components/blocked-users/index.ts deleted file mode 100644 index 3754548b..00000000 --- a/frontend/app/components/blocked-users/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import withTheme from '../../components/with-theme'; -import BlockedUsers from './blocked-users'; - -export default withTheme(BlockedUsers); - -require('./blocked-users.scss'); - -require('./__action/blocked-users__action.scss'); -require('./__list/blocked-users__list.scss'); - -require('./__list-item/blocked-users__list-item.scss'); -require('./__list-item/_view/_invisible/blocked-users__list-item_view_invisible.scss'); - -require('./__username/blocked-users__username.scss'); - -require('./_theme/_dark/blocked-users_theme_dark.scss'); -require('./_theme/_light/blocked-users_theme_light.scss'); diff --git a/frontend/app/components/comment/comment.test.tsx b/frontend/app/components/comment/comment.test.tsx index cc8a947e..14a26ebd 100644 --- a/frontend/app/components/comment/comment.test.tsx +++ b/frontend/app/components/comment/comment.test.tsx @@ -174,20 +174,26 @@ describe('', () => { container = domContainer; }); - it('visible for admin', () => { + it('for admin if shows admin controls', () => { const element = ; render(element, container); - const controls = container.querySelector('.comment__controls'); - expect(controls).not.toBe(null); + const controls = container.querySelectorAll('.comment__controls > span'); + expect(controls!.length).toBe(5); + expect(controls![0].textContent).toBe('Copy'); + expect(controls![1].textContent).toBe('Pin'); + expect(controls![2].textContent).toBe('Hide'); + expect(controls![3].childNodes[0].textContent).toBe('Block'); + expect(controls![4].textContent).toBe('Delete'); }); - it('not visible for regular user', () => { + it('for regular user it shows only "hide"', () => { const element = ; render(element, container); - const controls = container.querySelector('.comment__controls'); - expect(controls).toBe(null); + const controls = container.querySelectorAll('.comment__controls > span'); + expect(controls!.length).toBe(1); + expect(controls![0].textContent).toBe('Hide'); }); it('verification badge clickable for admin', () => { diff --git a/frontend/app/components/comment/comment.tsx b/frontend/app/components/comment/comment.tsx index 1756c131..817666e5 100644 --- a/frontend/app/components/comment/comment.tsx +++ b/frontend/app/components/comment/comment.tsx @@ -11,15 +11,16 @@ import { API_BASE, BASE_URL, COMMENT_NODE_CLASSNAME_PREFIX, BLOCKING_DURATIONS } import { StaticStore } from '@app/common/static_store'; import debounce from '@app/utils/debounce'; import copy from '@app/common/copy'; -import { Theme, BlockTTL, Comment as CommentType, PostInfo, User, CommentMode, Image } from '@app/common/types'; +import { Theme, BlockTTL, Comment as CommentType, PostInfo, User, CommentMode } from '@app/common/types'; import { extractErrorMessageFromResponse, FetcherError } from '@app/utils/errorUtils'; import { isUserAnonymous } from '@app/utils/isUserAnonymous'; import { Input } from '@app/components/input'; import { AvatarIcon } from '@app/components/avatar-icon'; import Countdown from '../countdown'; +import { boundActions } from './connected-comment'; -export interface Props { +export type Props = { user: User | null; data: CommentType; repliesCount?: number; @@ -42,21 +43,7 @@ export interface Props { theme: Theme; level?: number; mix?: string; - - // actions are optional, as component has read-only mode, such as in last comments - addComment?: (text: string, title: string, pid?: CommentType['id']) => Promise; - updateComment?: (id: CommentType['id'], text: string) => Promise; - removeComment?(id: CommentType['id']): Promise; - setReplyEditState?(id: CommentType['id'], mode: CommentMode): void; - getPreview?: (text: string) => Promise; - putCommentVote?(id: CommentType['id'], value: number): Promise; - setCollapse?: (id: CommentType['id'], value: boolean) => void; - setPinState?(id: CommentType['id'], value: boolean): Promise; - blockUser?(id: User['id'], name: User['name'], ttl: BlockTTL): Promise; - unblockUser?(id: User['id']): Promise; - setVerifyStatus?(id: User['id'], value: boolean): Promise; - uploadImage?(image: File): Promise; -} +} & Partial; export interface State { isCopied: boolean; @@ -105,7 +92,7 @@ export class Comment extends Component { this.updateState(nextProps); } - updateState(props: Props) { + updateState = (props: Props) => { this.setState({ scoreDelta: props.data.vote, cachedScore: props.data.score, @@ -125,52 +112,54 @@ export class Comment extends Component { }); } } - } + }; - toggleReplying() { + toggleReplying = () => { const { editMode } = this.props; if (editMode === CommentMode.Reply) { - this.props.setReplyEditState!(this.props.data.id, CommentMode.None); + this.props.setReplyEditState!({ id: this.props.data.id, state: CommentMode.None }); } else { - this.props.setReplyEditState!(this.props.data.id, CommentMode.Reply); + this.props.setReplyEditState!({ id: this.props.data.id, state: CommentMode.Reply }); } - } + }; - toggleEditing() { + toggleEditing = () => { const { editMode } = this.props; if (editMode === CommentMode.Edit) { - this.props.setReplyEditState!(this.props.data.id, CommentMode.None); + this.props.setReplyEditState!({ id: this.props.data.id, state: CommentMode.None }); } else { - this.props.setReplyEditState!(this.props.data.id, CommentMode.Edit); + this.props.setReplyEditState!({ id: this.props.data.id, state: CommentMode.Edit }); } - } + }; - toggleUserInfoVisibility() { + toggleUserInfoVisibility = () => { if (window.parent) { const { user } = this.props.data; const data = JSON.stringify({ isUserInfoShown: true, user }); window.parent.postMessage(data, '*'); } - } + }; - setPin(value: boolean) { + togglePin = () => { + const value = !this.props.data.pin; const promptMessage = `Do you want to ${value ? 'pin' : 'unpin'} this comment?`; if (confirm(promptMessage)) { this.props.setPinState!(this.props.data.id, value); } - } + }; - setVerify(value: boolean) { + toggleVerify = () => { + const value = !this.props.data.user.verified; const userId = this.props.data.user.id; - const promptMessage = `Do you want to ${value ? 'verify' : 'unverify'} this user?`; + const promptMessage = `Do you want to ${value ? 'verify' : 'unverify'} ${this.props.data.user.name}?`; if (confirm(promptMessage)) { this.props.setVerifyStatus!(userId, value); } - } + }; - onBlockUserClick(e: Event) { + onBlockUserClick = (e: Event) => { // blur event will be triggered by the confirm pop-up which will start // infinite loop of blur -> confirm -> blur -> ... // so we trigger the blur event manually and have debounce mechanism to prevent it @@ -180,9 +169,9 @@ export class Comment extends Component { // we have to debounce the blockUser function calls otherwise it will be // called 2 times (by change event and by blur event) this.blockUser((e.target as HTMLOptionElement).value as BlockTTL); - } + }; - blockUser(ttl: BlockTTL) { + blockUser = (ttl: BlockTTL) => { const { user } = this.props.data; const block_duration = BLOCKING_DURATIONS.find(el => el.value === ttl); @@ -194,9 +183,9 @@ export class Comment extends Component { if (confirm(`Do you want to block ${user.name} ${duration.toLowerCase()}?`)) { this.props.blockUser!(user.id, user.name, ttl); } - } + }; - onUnblockUserClick() { + onUnblockUserClick = () => { const { user } = this.props.data; const promptMessage = `Do you want to unblock this user?`; @@ -204,31 +193,36 @@ export class Comment extends Component { if (confirm(promptMessage)) { this.props.unblockUser!(user.id); } - } + }; - deleteComment() { + deleteComment = () => { if (confirm('Do you want to delete this comment?')) { - this.props.setReplyEditState!(this.props.data.id, CommentMode.None); + this.props.setReplyEditState!({ id: this.props.data.id, state: CommentMode.None }); this.props.removeComment!(this.props.data.id); } - } + }; - handleVoteError(e: FetcherError, originalScore: number, originalDelta: number) { + hideUser = () => { + if (!confirm(`Do you want to hide comments of ${this.props.data.user.name}?`)) return; + this.props.hideUser!(this.props.data.user); + }; + + handleVoteError = (e: FetcherError, originalScore: number, originalDelta: number) => { this.setState({ scoreDelta: originalDelta, cachedScore: originalScore, voteErrorMessage: extractErrorMessageFromResponse(e), }); - } + }; - sendVotingRequest(votingValue: number, originalScore: number, originalDelta: number) { + sendVotingRequest = (votingValue: number, originalScore: number, originalDelta: number) => { this.votingPromise = this.votingPromise .then(() => this.props.putCommentVote!(this.props.data.id, votingValue)) .catch(e => this.handleVoteError(e, originalScore, originalDelta)); - } + }; - increaseScore() { + increaseScore = () => { const { cachedScore, scoreDelta } = this.state; if (scoreDelta === 1) return; @@ -240,9 +234,9 @@ export class Comment extends Component { }); this.sendVotingRequest(1, cachedScore, scoreDelta); - } + }; - decreaseScore() { + decreaseScore = () => { const { cachedScore, scoreDelta } = this.state; if (scoreDelta === -1) return; @@ -254,21 +248,21 @@ export class Comment extends Component { }); this.sendVotingRequest(-1, cachedScore, scoreDelta); - } + }; - async addComment(text: string, title: string, pid?: CommentType['id']) { + addComment = async (text: string, title: string, pid?: CommentType['id']) => { await this.props.addComment!(text, title, pid); - this.props.setReplyEditState!(this.props.data.id, CommentMode.None); - } + this.props.setReplyEditState!({ id: this.props.data.id, state: CommentMode.None }); + }; - async updateComment(id: CommentType['id'], text: string) { + updateComment = async (id: CommentType['id'], text: string) => { await this.props.updateComment!(id, text); - this.props.setReplyEditState!(this.props.data.id, CommentMode.None); - } + this.props.setReplyEditState!({ id: this.props.data.id, state: CommentMode.None }); + }; - scrollToParent(e: Event) { + scrollToParent = (e: Event) => { const { data: { pid }, } = this.props; @@ -280,15 +274,17 @@ export class Comment extends Component { if (parentCommentNode) { parentCommentNode.scrollIntoView(); } - } + }; - toggleCollapse() { - this.props.setReplyEditState!(this.props.data.id, CommentMode.None); + toggleCollapse = () => { + this.props.setReplyEditState!({ id: this.props.data.id, state: CommentMode.None }); this.props.setCollapse!(this.props.data.id, !this.props.collapsed); - } + }; - copyComment({ username, time }: { username: string; time: string }) { + copyComment = () => { + const username = this.props.data.user.name; + const time = this.props.data.time; const text = this.textNode!.textContent || ''; copy(`${username} ${time}
${text.replace(/\n+/g, '
')}`); @@ -296,42 +292,42 @@ export class Comment extends Component { this.setState({ isCopied: true }, () => { setTimeout(() => this.setState({ isCopied: false }), 3000); }); - } + }; /** * Defines whether current client is admin */ - isAdmin(): boolean { + isAdmin = (): boolean => { return !!this.props.user && this.props.user.admin; - } + }; /** * Defines whether current client is not logged in */ - isGuest(): boolean { + isGuest = (): boolean => { return !this.props.user; - } + }; /** * Defines whether current client is logged in via `Anonymous provider` */ - isAnonymous(): boolean { + isAnonymous = (): boolean => { return isUserAnonymous(this.props.user); - } + }; /** * Defines whether comment made by logged in user */ - isCurrentUser(): boolean { + isCurrentUser = (): boolean => { if (this.isGuest()) return false; return this.props.data.user.id === this.props.user!.id; - } + }; /** * returns reason for disabled downvoting */ - getDownvoteDisabledReason(): string | null { + getDownvoteDisabledReason = (): string | null => { if (!(this.props.view === 'main' || this.props.view === 'pinned')) return "Voting allowed only on post's page"; if (this.props.post_info!.read_only) return "Can't vote on read-only topics"; if (this.props.data.delete) return "Can't vote for deleted comment"; @@ -340,12 +336,12 @@ export class Comment extends Component { if (this.isGuest()) return 'Sign in to vote'; if (this.isAnonymous()) return "Anonymous users can't vote"; return null; - } + }; /** * returns reason for disabled upvoting */ - getUpvoteDisabledReason(): string | null { + getUpvoteDisabledReason = (): string | null => { if (!(this.props.view === 'main' || this.props.view === 'pinned')) return "Voting allowed only on post's page"; if (this.props.post_info!.read_only) return "Can't vote on read-only topics"; if (this.props.data.delete) return "Can't vote for deleted comment"; @@ -353,7 +349,83 @@ export class Comment extends Component { if (this.isGuest()) return 'Sign in to vote'; if (this.isAnonymous()) return "Anonymous users can't vote"; return null; - } + }; + + getCommentControls = (): JSX.Element[] => { + const isAdmin = this.isAdmin(); + const isCurrentUser = this.isCurrentUser(); + const controls: JSX.Element[] = []; + + if (this.props.data.delete) { + return controls; + } + + if (!(this.props.view === 'main' || this.props.view === 'pinned')) { + return controls; + } + + if (isAdmin) { + controls.push( + this.state.isCopied ? ( + Copied! + ) : ( + + Copy + + ) + ); + + controls.push( + + {this.props.data.pin ? 'Unpin' : 'Pin'} + + ); + } + + if (!isCurrentUser) { + controls.push( + + Hide + + ); + } + + if (isAdmin) { + if (this.props.isUserBanned) { + controls.push( + + Unblock + + ); + } + + if (this.props.user!.id !== this.props.data.user.id && !this.props.isUserBanned) { + controls.push( + + Block + + + ); + } + + if (!this.props.data.delete) { + controls.push( + + Delete + + ); + } + } + return controls; + }; render(props: RenderableProps, state: State) { const isAdmin = this.isAdmin(); @@ -371,6 +443,7 @@ export class Comment extends Component { const editable = props.repliesCount === 0 && state.editDeadline; const scoreSignEnabled = !StaticStore.config.positive_score; const uploadImageHandler = this.isAnonymous() ? undefined : this.props.uploadImage; + const commentControls = this.getCommentControls(); /** * CommentType adapted for rendering @@ -474,7 +547,7 @@ export class Comment extends Component { {props.view !== 'user' && ( this.toggleUserInfoVisibility())} + {...getHandleClickProps(this.toggleUserInfoVisibility)} className="comment__username" title={o.user.id} > @@ -484,7 +557,7 @@ export class Comment extends Component { {isAdmin && props.view !== 'user' && ( this.setVerify(!o.user.verified))} + {...getHandleClickProps(this.toggleVerify)} aria-label="Toggle verification" title={o.user.verified ? 'Verified user' : 'Unverified user'} className={b('comment__verification', {}, { active: o.user.verified, clickable: true })} @@ -517,7 +590,7 @@ export class Comment extends Component { {!props.disabled && props.view === 'main' && ( this.toggleCollapse())} + {...getHandleClickProps(this.toggleCollapse)} className={b('comment__action', {}, { type: 'collapse', selected: props.collapsed })} > {props.collapsed ? '+' : '−'} @@ -532,7 +605,7 @@ export class Comment extends Component { { type: 'up', selected: state.scoreDelta === 1, disabled: isUpvotingDisabled } )} aria-disabled={state.scoreDelta === 1 || isUpvotingDisabled ? 'true' : 'false'} - {...getHandleClickProps(isUpvotingDisabled ? undefined : () => this.increaseScore())} + {...getHandleClickProps(isUpvotingDisabled ? undefined : this.increaseScore)} title={upvotingDisabledReason || undefined} > Vote up @@ -550,7 +623,7 @@ export class Comment extends Component { { type: 'down', selected: state.scoreDelta === -1, disabled: isDownvotingDisabled } )} aria-disabled={state.scoreDelta === -1 || isUpvotingDisabled ? 'true' : 'false'} - {...getHandleClickProps(isDownvotingDisabled ? undefined : () => this.decreaseScore())} + {...getHandleClickProps(isDownvotingDisabled ? undefined : this.decreaseScore)} title={downvotingDisabledReason || undefined} > Vote down @@ -575,7 +648,7 @@ export class Comment extends Component { {(!props.collapsed || props.view === 'pinned') && (
{!props.data.delete && !props.isCommentsDisabled && !props.disabled && !isGuest && props.view === 'main' && ( - this.toggleReplying())} className="comment__action"> + {isReplying ? 'Cancel' : 'Reply'} )} @@ -587,14 +660,14 @@ export class Comment extends Component { (editable || isEditing) && props.view === 'main' && [ this.toggleEditing())} + {...getHandleClickProps(this.toggleEditing)} className="comment__action comment__action_type_edit" > {isEditing ? 'Cancel' : 'Edit'} , !isAdmin && ( this.deleteComment())} + {...getHandleClickProps(this.deleteComment)} className="comment__action comment__action_type_delete" > Delete @@ -613,57 +686,7 @@ export class Comment extends Component { ), ]} - {!props.data.delete && isAdmin && ( - - {!state.isCopied && ( - this.copyComment({ username: o.user.name, time: o.time }))} - className="comment__control" - > - Copy - - )} - - {state.isCopied && Copied!} - - {(props.view === 'main' || props.view === 'pinned') && ( - this.setPin(!props.data.pin))} className="comment__control"> - {props.data.pin ? 'Unpin' : 'Pin'} - - )} - - {props.isUserBanned && ( - this.onUnblockUserClick())} className="comment__control"> - Unblock - - )} - - {props.user!.id !== props.data.user.id && !props.isUserBanned && ( - - Block - - - )} - - {!props.data.delete && ( - this.deleteComment())} className="comment__control"> - Delete - - )} - - )} + {commentControls.length > 0 && {commentControls}}
)}
diff --git a/frontend/app/components/comment/connected-comment.ts b/frontend/app/components/comment/connected-comment.ts index ab0b3554..06fa5d2a 100644 --- a/frontend/app/components/comment/connected-comment.ts +++ b/frontend/app/components/comment/connected-comment.ts @@ -3,11 +3,11 @@ * and should be importded explicitly */ -import { Comment as CommentType, User, BlockTTL, CommentMode } from '@app/common/types'; +import { Comment as CommentType } from '@app/common/types'; import { connect } from 'preact-redux'; -import { StoreState, StoreDispatch } from '@app/store'; +import { StoreState } from '@app/store'; import { addComment, removeComment, @@ -17,14 +17,15 @@ import { setCommentMode, } from '@app/store/comments/actions'; import { setCollapse } from '@app/store/thread/actions'; -import { blockUser, unblockUser, setVirifiedStatus } from '@app/store/user/actions'; +import { blockUser, unblockUser, hideUser, setVerifiedStatus } from '@app/store/user/actions'; import { Comment, Props } from './comment'; import { getCommentMode } from '@app/store/comments/getters'; -import { uploadImage } from '@app/common/api'; +import { uploadImage, getPreview } from '@app/common/api'; import { getThreadIsCollapsed } from '@app/store/thread/getters'; +import { bindActions } from '@app/utils/actionBinder'; -const mapProps = (state: StoreState, cprops: { data: CommentType }) => { +const mapStateToProps = (state: StoreState, cprops: { data: CommentType }) => { const props: Pick< Props, 'editMode' | 'user' | 'isUserBanned' | 'post_info' | 'isCommentsDisabled' | 'theme' | 'collapsed' @@ -40,41 +41,24 @@ const mapProps = (state: StoreState, cprops: { data: CommentType }) => { return props; }; -const mapDispatchToProps = (dispatch: StoreDispatch) => { - const props: Pick< - Props, - | 'addComment' - | 'updateComment' - | 'removeComment' - | 'setReplyEditState' - | 'setCollapse' - | 'setPinState' - | 'putCommentVote' - | 'blockUser' - | 'unblockUser' - | 'setVerifyStatus' - | 'uploadImage' - > = { - addComment: (text: string, title: string, pid?: CommentType['id']) => dispatch(addComment(text, title, pid)), - updateComment: (id: CommentType['id'], text: string) => dispatch(updateComment(id, text)), - removeComment: (id: CommentType['id']) => dispatch(removeComment(id)), - setReplyEditState: (id: CommentType['id'], mode: CommentMode) => dispatch(setCommentMode({ id, state: mode })), - setCollapse: (id: CommentType['id'], value: boolean) => dispatch(setCollapse(id, value)), - setPinState: (id: CommentType['id'], value: boolean) => dispatch(setPinState(id, value)), - putCommentVote: (id: CommentType['id'], value: number) => dispatch(putVote(id, value)), - - blockUser: (id: User['id'], name: User['name'], ttl: BlockTTL) => dispatch(blockUser(id, name, ttl)), - unblockUser: (id: User['id']) => dispatch(unblockUser(id)), - setVerifyStatus: (id: User['id'], value: boolean) => dispatch(setVirifiedStatus(id, value)), - // should i made it as store action? - uploadImage: (image: File) => uploadImage(image), - }; - - return props; -}; +export const boundActions = bindActions({ + addComment, + updateComment, + removeComment, + setReplyEditState: setCommentMode, + setCollapse, + setPinState, + putCommentVote: putVote, + blockUser, + unblockUser, + hideUser, + setVerifyStatus: setVerifiedStatus, + uploadImage, + getPreview, +}); /** Comment component connected to redux */ export const ConnectedComment = connect( - mapProps, - mapDispatchToProps + mapStateToProps, + boundActions as Partial )(Comment); diff --git a/frontend/app/components/root/root.tsx b/frontend/app/components/root/root.tsx index 9c41d3d1..e9f48fa2 100644 --- a/frontend/app/components/root/root.tsx +++ b/frontend/app/components/root/root.tsx @@ -9,12 +9,9 @@ import { PostInfo, BlockedUser, Comment as CommentType, - Tree, Sorting, Theme, AuthProvider, - BlockTTL, - Image, } from '@app/common/types'; import { NODE_ID, @@ -26,7 +23,7 @@ import { import { maxShownComments } from '@app/common/settings'; import { StaticStore } from '@app/common/static_store'; -import { StoreState, StoreDispatch } from '@app/store'; +import { StoreState } from '@app/store'; import { fetchUser, logout, @@ -34,7 +31,9 @@ import { blockUser, unblockUser, fetchBlockedUsers, - setBlockedVisibleState, + setSettingsVisibleState, + hideUser, + unhideUser, } from '@app/store/user/actions'; import { fetchComments } from '@app/store/comments/actions'; import { setCommentsReadOnlyState } from '@app/store/post_info/actions'; @@ -43,41 +42,47 @@ import { setSort } from '@app/store/sort/actions'; import { addComment, updateComment } from '@app/store/comments/actions'; import { AuthPanel } from '@app/components/auth-panel'; -import BlockedUsers from '@app/components/blocked-users'; +import Settings from '@app/components/settings'; import { ConnectedComment as Comment } from '@app/components/comment/connected-comment'; import { Input } from '@app/components/input'; import Preloader from '@app/components/preloader'; import { Thread } from '@app/components/thread'; -import { uploadImage } from '@app/common/api'; +import { uploadImage, getPreview } from '@app/common/api'; import { isUserAnonymous } from '@app/utils/isUserAnonymous'; +import { bindActions } from '@app/utils/actionBinder'; -interface Props { +const boundActions = bindActions({ + fetchComments, + fetchUser, + fetchBlockedUsers, + setSettingsVisible: setSettingsVisibleState, + logIn, + logOut: logout, + setTheme, + getPreview, + enableComments: () => setCommentsReadOnlyState(false), + disableComments: () => setCommentsReadOnlyState(true), + changeSort: setSort, + blockUser, + unblockUser, + hideUser, + unhideUser, + addComment, + updateComment, + uploadImage, +}); + +type Props = { user: User | null; sort: Sorting; comments: Node[]; pinnedComments: CommentType[]; theme: Theme; info: PostInfo; - bannedUsers: BlockedUser[]; - isBlockedVisible: boolean; - - fetchComments(sort: Sorting): Promise; - fetchUser(): Promise; - fetchBlockedUsers(): Promise; - logIn(p: AuthProvider): Promise; - logOut(): Promise; - setTheme: (theme: Theme) => void; - setBlockedVisible: (value: boolean) => boolean; - changeSort(sort: Sorting): Promise; - enableComments(): Promise; - disableComments(): Promise; - getPreview(text: string): Promise; - blockUser(id: User['id'], name: User['name'], ttl: BlockTTL): Promise; - unblockUser(id: User['id']): Promise; - addComment(text: string, title: string, pid?: CommentType['id']): Promise; - updateComment(id: string, text: string): Promise; - uploadImage(image: File): Promise; -} + hiddenUsers: StoreState['hiddenUsers']; + blockedUsers: BlockedUser[]; + isSettingsVisible: boolean; +} & typeof boundActions; interface State { isLoaded: boolean; @@ -159,21 +164,22 @@ export class Root extends Component { } } - onBlockedUsersShow() { - this.props.fetchBlockedUsers().then(() => { - this.props.setBlockedVisible(true); - }); + async onBlockedUsersShow() { + if (this.props.user && this.props.user.admin) { + await this.props.fetchBlockedUsers(); + } + this.props.setSettingsVisible(true); } - onBlockedUsersHide() { + async onBlockedUsersHide() { // if someone was unblocked let's reload comments if (this.state.wasSomeoneUnblocked) { this.props.fetchComments(this.props.sort); } - this.props.setBlockedVisible(false), - this.setState({ - wasSomeoneUnblocked: false, - }); + this.props.setSettingsVisible(false); + this.setState({ + wasSomeoneUnblocked: false, + }); } async changeSort(sort: Sorting) { @@ -221,6 +227,7 @@ export class Root extends Component { { onSortChange={this.props.changeSort} /> - {!this.props.isBlockedVisible && ( + {!this.props.isSettingsVisible && (
{!isGuest && !isCommentsDisabled && ( {
)} - {this.props.isBlockedVisible && ( + {this.props.isSettingsVisible && (
-
@@ -307,38 +318,18 @@ export class Root extends Component { } } -const mapDispatchToProps = (dispatch: StoreDispatch) => { - return { - fetchComments: (sort: Sorting) => dispatch(fetchComments(sort)), - fetchUser: () => dispatch(fetchUser()), - fetchBlockedUsers: () => dispatch(fetchBlockedUsers()), - setBlockedVisible: (value: boolean) => dispatch(setBlockedVisibleState(value)), - logIn: (provider: AuthProvider) => dispatch(logIn(provider)), - logOut: () => dispatch(logout()), - setTheme: (theme: Theme) => dispatch(setTheme(theme)), - enableComments: () => dispatch(setCommentsReadOnlyState(false)), - disableComments: () => dispatch(setCommentsReadOnlyState(true)), - changeSort: (sort: Sorting) => dispatch(setSort(sort)), - blockUser: (id: User['id'], name: User['name'], ttl: BlockTTL) => dispatch(blockUser(id, name, ttl)), - unblockUser: (id: User['id']) => dispatch(unblockUser(id)), - addComment: (text: string, pageTitle: string, pid?: CommentType['id']) => - dispatch(addComment(text, pageTitle, pid)), - updateComment: (id: CommentType['id'], text: string) => dispatch(updateComment(id, text)), - uploadImage: (image: File) => uploadImage(image), - }; -}; - /** Root component connected to redux */ export const ConnectedRoot = connect( (state: StoreState) => ({ user: state.user, sort: state.sort, - isBlockedVisible: state.isBlockedVisible, + isSettingsVisible: state.isSettingsVisible, comments: state.comments, pinnedComments: state.pinnedComments, theme: state.theme, info: state.info, - bannedUsers: state.bannedUsers, + hiddenUsers: state.hiddenUsers, + blockedUsers: state.bannedUsers, }), - mapDispatchToProps + boundActions )(Root); diff --git a/frontend/app/components/blocked-users/__action/blocked-users__action.scss b/frontend/app/components/settings/__action/settings__action.scss similarity index 70% rename from frontend/app/components/blocked-users/__action/blocked-users__action.scss rename to frontend/app/components/settings/__action/settings__action.scss index 940cc969..2e256e14 100644 --- a/frontend/app/components/blocked-users/__action/blocked-users__action.scss +++ b/frontend/app/components/settings/__action/settings__action.scss @@ -1,13 +1,9 @@ -.blocked-users__action { +.settings__action { margin-left: 8px; font-weight: 700; color: #0aa; cursor: pointer; - @media (hover: hover) { - opacity: 0; - } - &:hover { color: #06c5c5; } diff --git a/frontend/app/components/settings/__dimmed/settings__dimmed.scss b/frontend/app/components/settings/__dimmed/settings__dimmed.scss new file mode 100644 index 00000000..6761c723 --- /dev/null +++ b/frontend/app/components/settings/__dimmed/settings__dimmed.scss @@ -0,0 +1,3 @@ +.settings__dimmed { + opacity: 0.5; +} diff --git a/frontend/app/components/settings/__invisible/settings__invisible.scss b/frontend/app/components/settings/__invisible/settings__invisible.scss new file mode 100644 index 00000000..1565ce7e --- /dev/null +++ b/frontend/app/components/settings/__invisible/settings__invisible.scss @@ -0,0 +1,3 @@ +.settings__invisible { + opacity: 0.4; +} diff --git a/frontend/app/components/settings/__list/settings__list.scss b/frontend/app/components/settings/__list/settings__list.scss new file mode 100644 index 00000000..55e55b27 --- /dev/null +++ b/frontend/app/components/settings/__list/settings__list.scss @@ -0,0 +1,21 @@ +.settings__list { + padding: 0 0 0 20px; +} + +.settings__list-item { + cursor: default; + position: relative; + margin-bottom: 0.5em; + + @media (hover: hover) { + .settings__action { + opacity: 0; + } + + &:hover { + .settings__action { + opacity: 1; + } + } + } +} diff --git a/frontend/app/components/settings/__section/settings__section.scss b/frontend/app/components/settings/__section/settings__section.scss new file mode 100644 index 00000000..3d02754f --- /dev/null +++ b/frontend/app/components/settings/__section/settings__section.scss @@ -0,0 +1,3 @@ +.settings__section + .settings__section { + margin-top: 2em; +} diff --git a/frontend/app/components/settings/__user-id/settings__user-id.scss b/frontend/app/components/settings/__user-id/settings__user-id.scss new file mode 100644 index 00000000..bcd5124e --- /dev/null +++ b/frontend/app/components/settings/__user-id/settings__user-id.scss @@ -0,0 +1,5 @@ +.settings__user-id { + font-style: italic; + font-size: 0.8em; + word-break: break-all; +} diff --git a/frontend/app/components/settings/__username/settings__username.scss b/frontend/app/components/settings/__username/settings__username.scss new file mode 100644 index 00000000..be4fbd36 --- /dev/null +++ b/frontend/app/components/settings/__username/settings__username.scss @@ -0,0 +1,3 @@ +.settings__username { + font-weight: 700; +} diff --git a/frontend/app/components/settings/_theme/_dark/settings_theme_dark.scss b/frontend/app/components/settings/_theme/_dark/settings_theme_dark.scss new file mode 100644 index 00000000..f818ffeb --- /dev/null +++ b/frontend/app/components/settings/_theme/_dark/settings_theme_dark.scss @@ -0,0 +1,11 @@ +.settings_theme_dark { + .settings__action { + &::before { + color: #ddd; + } + } + + .settings__blocked-users-username { + color: #eee; + } +} diff --git a/frontend/app/components/settings/_theme/_light/settings_theme_light.scss b/frontend/app/components/settings/_theme/_light/settings_theme_light.scss new file mode 100644 index 00000000..f9540ebd --- /dev/null +++ b/frontend/app/components/settings/_theme/_light/settings_theme_light.scss @@ -0,0 +1,11 @@ +.settings_theme_light { + .settings__action { + &::before { + color: #777; + } + } + + .settings__blocked-users-username { + color: #888; + } +} diff --git a/frontend/app/components/settings/index.ts b/frontend/app/components/settings/index.ts new file mode 100644 index 00000000..085058aa --- /dev/null +++ b/frontend/app/components/settings/index.ts @@ -0,0 +1,16 @@ +import withTheme from '../../components/with-theme'; +import Settings from './settings'; + +export default withTheme(Settings); + +require('./settings.scss'); + +require('./__action/settings__action.scss'); +require('./__section/settings__section.scss'); +require('./__list/settings__list.scss'); +require('./__invisible/settings__invisible.scss'); +require('./__dimmed/settings__dimmed.scss'); +require('./__username/settings__username.scss'); +require('./__user-id/settings__user-id.scss'); +require('./_theme/_dark/settings_theme_dark.scss'); +require('./_theme/_light/settings_theme_light.scss'); diff --git a/frontend/app/components/blocked-users/blocked-users.scss b/frontend/app/components/settings/settings.scss similarity index 55% rename from frontend/app/components/blocked-users/blocked-users.scss rename to frontend/app/components/settings/settings.scss index 48be048c..dc479a86 100644 --- a/frontend/app/components/blocked-users/blocked-users.scss +++ b/frontend/app/components/settings/settings.scss @@ -1,3 +1,3 @@ -.blocked-users { +.settings { padding: 10px 0; } diff --git a/frontend/app/components/settings/settings.tsx b/frontend/app/components/settings/settings.tsx new file mode 100644 index 00000000..1ced460a --- /dev/null +++ b/frontend/app/components/settings/settings.tsx @@ -0,0 +1,178 @@ +/** @jsx h */ +import { h, Component, RenderableProps } from 'preact'; +import b from 'bem-react-helper'; + +import { User, BlockedUser, Theme, BlockTTL } from '@app/common/types'; +import { getHandleClickProps } from '@app/common/accessibility'; +import { StoreState } from '@app/store'; + +interface Props { + theme: Theme; + user: StoreState['user']; + blockedUsers: BlockedUser[]; + hiddenUsers: StoreState['hiddenUsers']; + blockUser(id: User['id'], name: string, ttl: BlockTTL): Promise; + unblockUser(id: User['id']): Promise; + hideUser(user: User): void; + unhideUser(userid: User['id']): void; + onUnblockSomeone(): void; +} + +interface State { + /** + * cached copy so we can + * reapply block on unblocked user + */ + blockedUsers: BlockedUser[]; + unblockedUsers: (User['id'])[]; + hiddenUsers: { [id: string]: User }; + unhiddenUsers: (User['id'])[]; +} + +export default class BlockedUsers extends Component { + constructor(props: Props) { + super(props); + + this.state = { + blockedUsers: props.blockedUsers.slice(), + unblockedUsers: [], + hiddenUsers: { ...props.hiddenUsers }, + unhiddenUsers: [], + }; + } + + block = (user: BlockedUser) => { + if (!confirm(`Do you want to block ${user.name}?`)) return; + this.setState({ + unblockedUsers: this.state.unblockedUsers.filter(x => x !== user.id), + }); + this.props.blockUser(user.id, user.name, 'permanently'); + }; + + unblock = (user: BlockedUser) => { + if (!confirm(`Do you want to unblock ${user.name}?`)) return; + this.setState({ unblockedUsers: this.state.unblockedUsers.concat([user.id]) }); + this.props.unblockUser(user.id); + this.props.onUnblockSomeone(); + }; + + hide = (user: User) => { + this.setState({ + unhiddenUsers: this.state.unhiddenUsers.filter(x => x !== user.id), + }); + this.props.hideUser(user); + }; + + unhide = (user: User) => { + this.setState({ unhiddenUsers: this.state.unhiddenUsers.concat([user.id]) }); + this.props.unhideUser(user.id); + this.props.onUnblockSomeone(); + }; + + __isUserHidden = (user: User): boolean => { + if (this.state.unhiddenUsers.indexOf(user.id) === -1) return true; + return false; + }; + + render({ user, theme }: RenderableProps, { blockedUsers, unblockedUsers, unhiddenUsers }: State) { + const hiddenUsersList = Object.values(this.state.hiddenUsers); + return ( +
+
+

Hidden users:

+ {!hiddenUsersList.length &&

There are no hidden users.

} + {!!hiddenUsersList.length && ( +
    + {hiddenUsersList.map(user => { + const isUserUnhidden = unhiddenUsers.includes(user.id); + + return ( +
  • + + {user.name || 'unknown'} + + {this.__isUserHidden(user) ? ( + this.unhide(user))}> + show + + ) : ( + this.hide(user))}> + hide + + )} +
    + + id: {user.id} + +
    +
  • + ); + })} +
+ )} +
+ {user && user.admin && ( +
+

Blocked users:

+ + {!blockedUsers.length &&

There are no blocked users.

} + + {!!blockedUsers.length && ( +
    + {blockedUsers.map(user => { + const isUserUnblocked = unblockedUsers.includes(user.id); + + return ( +
  • + + {user.name || 'unknown'} + + {formatTime(new Date(user.time))} + {isUserUnblocked && ( + this.block(user))} className="blocked-users__action"> + block + + )} + {!isUserUnblocked && ( + this.unblock(user))} className="settings__action"> + unblock + + )} +
    + + id: {user.id} + +
    +
  • + ); + })} +
+ )} +
+ )} +
+ ); + } +} + +const currentYear = new Date().getFullYear(); + +function formatTime(time: Date): string { + // let's assume that if block ttl is more than 50 years then user blocked permanently + if (time.getFullYear() - currentYear >= 50) return 'permanently'; + + // 'ru-RU' adds a dot as a separator + const date = time.toLocaleDateString(['ru-RU'], { day: '2-digit', month: '2-digit', year: '2-digit' }); + + // do it manually because Intl API doesn't add leading zeros to hours; idk why + const hours = `0${time.getHours()}`.slice(-2); + const mins = `0${time.getMinutes()}`.slice(-2); + + return `until ${date} at ${hours}:${mins}`; +} diff --git a/frontend/app/components/thread/thread.tsx b/frontend/app/components/thread/thread.tsx index 888526da..5d37127f 100644 --- a/frontend/app/components/thread/thread.tsx +++ b/frontend/app/components/thread/thread.tsx @@ -31,7 +31,7 @@ function Thread(props: RenderableProps) { role={['listitem'].concat(!collapsed && replies.length ? 'list' : []).join(' ')} aria-expanded={!collapsed} > - + {!collapsed && !!replies.length && diff --git a/frontend/app/remark.tsx b/frontend/app/remark.tsx index 9851c5d7..9eaa17e9 100644 --- a/frontend/app/remark.tsx +++ b/frontend/app/remark.tsx @@ -16,6 +16,8 @@ import '@app/components/list-comments'; import { NODE_ID, BASE_URL } from '@app/common/constants'; import { StaticStore } from '@app/common/static_store'; import api from '@app/common/api'; +import { bindActionCreators } from 'redux'; +import { fetchHiddenUsers } from './store/user/actions'; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); @@ -35,6 +37,9 @@ async function init(): Promise { return; } + const boundFetchHiddenUsers = bindActionCreators(fetchHiddenUsers, reduxStore.dispatch); + boundFetchHiddenUsers(); + const params = window.location.search .replace(/^\?/, '') .split('&') @@ -63,7 +68,7 @@ async function init(): Promise { } else { render( - + , node.parentElement!, node diff --git a/frontend/app/store/comments/actions.ts b/frontend/app/store/comments/actions.ts index d939ee8d..5fbeb4bd 100644 --- a/frontend/app/store/comments/actions.ts +++ b/frontend/app/store/comments/actions.ts @@ -9,6 +9,7 @@ import { replaceComment as uReplaceComment, removeComment as uRemoveComment, setCommentPin as uSetCommentPin, + filterTree, } from './utils'; import { COMMENTS_SET, PINNED_COMMENTS_SET, COMMENT_MODE_SET } from './types'; @@ -80,8 +81,11 @@ export const removeComment = (id: Comment['id']): StoreAction> => }; /** fetches comments from server */ -export const fetchComments = (sort: Sorting): StoreAction> => async dispatch => { +export const fetchComments = (sort: Sorting): StoreAction> => async (dispatch, getState) => { const data = await api.getPostComments(sort); + const hiddenUsersIds = Object.keys(getState().hiddenUsers); + if (hiddenUsersIds.length > 0) + data.comments = filterTree(data.comments, node => hiddenUsersIds.indexOf(node.comment.user.id) === -1); dispatch(setComments(data.comments)); dispatch({ type: POST_INFO_SET, diff --git a/frontend/app/store/comments/utils.ts b/frontend/app/store/comments/utils.ts index 8414a9e1..689add16 100644 --- a/frontend/app/store/comments/utils.ts +++ b/frontend/app/store/comments/utils.ts @@ -37,6 +37,27 @@ function mapTreeIfID(tree: Node[], id: Comment['id'], fn: (c: Node) => Node): No return treeClone; } +/** + * Filters tree node + */ +export function filterTree(tree: Node[], fn: (node: Node) => boolean): Node[] { + let filtered = false; + const newTree = tree.reduce((tree, node) => { + if (!fn(node)) { + filtered = true; + return tree; + } + const newNode: Node = !node.replies ? node : { ...node, replies: filterTree(node.replies, fn) }; + if (newNode !== node) { + filtered = true; + } + tree.push(newNode); + return tree; + }, []); + if (!filtered) return tree; + return newTree; +} + /** * Traverses through tree and applies function to comment on which function passed. * Note that function must not mutate comment diff --git a/frontend/app/store/index.ts b/frontend/app/store/index.ts index 3d8db78e..3800808a 100644 --- a/frontend/app/store/index.ts +++ b/frontend/app/store/index.ts @@ -1,4 +1,4 @@ -import { createStore, applyMiddleware } from 'redux'; +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'; @@ -23,8 +23,10 @@ export interface StoreState { info: PostInfo; /** List of banned users */ bannedUsers: BlockedUser[]; + /** List of hidden users */ + hiddenUsers: { [id: string]: User }; /** Whether list of blocked users should be visible */ - isBlockedVisible: boolean; + isSettingsVisible: boolean; /** Map of collapsed threads */ collapsedThreads: { [key: string]: boolean; @@ -41,11 +43,23 @@ const middleware = applyMiddleware(thunk); /** * Thunk Action shortcut */ -export type StoreAction = ThunkAction; +export type StoreAction = ThunkAction; /** * Thunk Dispatch shortcut */ export type StoreDispatch = ThunkDispatch; -export default createStore(reducers, middleware); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ + : compose; +const store = createStore(reducers, composeEnhancers(middleware)); + +if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).ReduxStore = store; +} + +export default store; diff --git a/frontend/app/store/reducers.ts b/frontend/app/store/reducers.ts index bfaa5f44..2c7a30db 100644 --- a/frontend/app/store/reducers.ts +++ b/frontend/app/store/reducers.ts @@ -1,16 +1,16 @@ import comments from './comments/reducers'; import postinfo from './post_info/reducers'; +import sort from './sort/reducers'; import theme from './theme/reducers'; import user from './user/reducers'; import userInfo from './user-info/reducers'; import thread from './thread/reducers'; -import sort from './sort/reducers'; /** Merged store reducers */ export default { - ...sort, ...comments, ...postinfo, + ...sort, ...theme, ...user, ...userInfo, diff --git a/frontend/app/store/user/actions.ts b/frontend/app/store/user/actions.ts index 2e4c591a..ed384fcb 100644 --- a/frontend/app/store/user/actions.ts +++ b/frontend/app/store/user/actions.ts @@ -3,9 +3,24 @@ import { User, BlockedUser, AuthProvider, BlockTTL } from '@app/common/types'; import { ttlToTime } from '@app/utils/ttl-to-time'; import { StoreAction } from '../index'; -import { USER_BAN, USER_SET, USER_UNBAN, BLOCKED_VISIBLE_SET, USER_BANLIST_SET } from './types'; +import { + USER_BAN, + USER_SET, + USER_UNBAN, + USER_BANLIST_SET, + USER_HIDELIST_SET_ACTION, + USER_HIDELIST_SET, + USER_HIDE_ACTION, + USER_HIDE, + USER_UNHIDE_ACTION, + USER_UNHIDE, + SETTINGS_VISIBLE_SET, +} from './types'; import { setComments, unsetCommentMode } from '../comments/actions'; -import { setUserVerified as uSetUserVerified } from '../comments/utils'; +import { setUserVerified as uSetUserVerified, filterTree } from '../comments/utils'; +import { IS_STORAGE_AVAILABLE, LS_HIDDEN_USERS_KEY } from '@app/common/constants'; +import { getItem } from '@app/common/local-storage'; +import { Dispatch } from 'redux'; export const fetchUser = (): StoreAction> => async dispatch => { const user = await api.getUser(); @@ -67,7 +82,37 @@ export const unblockUser = (id: User['id']): StoreAction> => async }); }; -export const setVirifiedStatus = (id: User['id'], status: boolean): StoreAction> => async ( +export const fetchHiddenUsers = (): StoreAction => dispatch => { + if (!IS_STORAGE_AVAILABLE) return; + + const hiddenUsers = JSON.parse(getItem(LS_HIDDEN_USERS_KEY) || '{}'); + return (dispatch as Dispatch)({ type: USER_HIDELIST_SET, payload: hiddenUsers }); +}; + +export const hideUser = (user: User): StoreAction => (dispatch, getState) => { + if (IS_STORAGE_AVAILABLE) { + const hiddenUsers = JSON.parse(getItem(LS_HIDDEN_USERS_KEY) || '{}'); + hiddenUsers[user.id] = user; + localStorage.setItem(LS_HIDDEN_USERS_KEY, JSON.stringify(hiddenUsers)); + } + (dispatch as Dispatch)({ type: USER_HIDE, user }); + + const comments = getState().comments; + return dispatch(setComments(filterTree(comments, node => node.comment.user.id !== user.id))); +}; + +export const unhideUser = (userId: string): StoreAction => dispatch => { + if (IS_STORAGE_AVAILABLE) { + const hiddenUsers = JSON.parse(getItem(LS_HIDDEN_USERS_KEY) || '{}'); + if (hiddenUsers.hasOwnProperty(userId)) { + delete hiddenUsers[userId]; + } + localStorage.setItem(LS_HIDDEN_USERS_KEY, JSON.stringify(hiddenUsers)); + } + return (dispatch as Dispatch)({ type: USER_UNHIDE, id: userId }); +}; + +export const setVerifiedStatus = (id: User['id'], status: boolean): StoreAction> => async ( dispatch, getState ) => { @@ -80,9 +125,9 @@ export const setVirifiedStatus = (id: User['id'], status: boolean): StoreAction< dispatch(setComments(uSetUserVerified(comments, id, status))); }; -export const setBlockedVisibleState = (state: boolean): StoreAction => dispatch => { +export const setSettingsVisibleState = (state: boolean): StoreAction => dispatch => { dispatch({ - type: BLOCKED_VISIBLE_SET, + type: SETTINGS_VISIBLE_SET, state, }); return state; diff --git a/frontend/app/store/user/reducers.ts b/frontend/app/store/user/reducers.ts index 6e7acfa6..fe3affff 100644 --- a/frontend/app/store/user/reducers.ts +++ b/frontend/app/store/user/reducers.ts @@ -6,9 +6,12 @@ import { USER_BAN, USER_UNBAN, USER_ACTIONS, - BLOCKED_VISIBLE_SET_ACTION, - BLOCKED_VISIBLE_SET, + SETTINGS_VISIBLE_SET_ACTION, + SETTINGS_VISIBLE_SET, USER_BANLIST_SET, + USER_HIDELIST_SET, + USER_HIDE, + USER_UNHIDE, } from './types'; export const user = (state: StoreState['user'] = null, action: USER_ACTIONS): User | null => { @@ -44,12 +47,31 @@ export const bannedUsers = (state: StoreState['bannedUsers'] = [], action: USER_ } }; -export const isBlockedVisible = ( - state: StoreState['isBlockedVisible'] = false, - action: BLOCKED_VISIBLE_SET_ACTION +export const hiddenUsers = (state: StoreState['hiddenUsers'] = {}, action: USER_ACTIONS): StoreState['hiddenUsers'] => { + switch (action.type) { + case USER_HIDELIST_SET: { + return action.payload; + } + case USER_HIDE: { + return { ...state, [action.user.id]: action.user }; + } + case USER_UNHIDE: { + if (!state.hasOwnProperty(action.id)) return state; + const newState = { ...state }; + delete newState[action.id]; + return newState; + } + default: + return state; + } +}; + +export const isSettingsVisible = ( + state: StoreState['isSettingsVisible'] = false, + action: SETTINGS_VISIBLE_SET_ACTION ): boolean => { switch (action.type) { - case BLOCKED_VISIBLE_SET: { + case SETTINGS_VISIBLE_SET: { return action.state; } default: @@ -57,4 +79,4 @@ export const isBlockedVisible = ( } }; -export default { user, bannedUsers, isBlockedVisible }; +export default { user, bannedUsers, hiddenUsers, isSettingsVisible }; diff --git a/frontend/app/store/user/types.ts b/frontend/app/store/user/types.ts index bd28f5ff..96fb6b2b 100644 --- a/frontend/app/store/user/types.ts +++ b/frontend/app/store/user/types.ts @@ -11,30 +11,48 @@ export interface USER_SET_ACTION { * Set list of banned users */ export const USER_BANLIST_SET = 'USER/BANLIST_SET'; - export interface USER_BANLIST_SET_ACTION { type: typeof USER_BANLIST_SET; list: BlockedUser[]; } export const USER_BAN = 'USER/BAN'; - export interface USER_BAN_ACTION { type: typeof USER_BAN; user: BlockedUser; } export const USER_UNBAN = 'USER/UNBAN'; - export interface USER_UNBAN_ACTION { type: typeof USER_UNBAN; id: User['id']; } -export const BLOCKED_VISIBLE_SET = 'BLOCKED_VISIBLE/SET'; +/** + * Set list of hidden users + */ +export const USER_HIDELIST_SET = 'USER/HIDELIST_SET'; +export interface USER_HIDELIST_SET_ACTION { + type: typeof USER_HIDELIST_SET; + payload: { [id: string]: User }; +} -export interface BLOCKED_VISIBLE_SET_ACTION { - type: typeof BLOCKED_VISIBLE_SET; +export const USER_HIDE = 'USER/HIDE'; +export interface USER_HIDE_ACTION { + type: typeof USER_HIDE; + user: User; +} + +export const USER_UNHIDE = 'USER/UNHIDE'; +export interface USER_UNHIDE_ACTION { + type: typeof USER_UNHIDE; + id: User['id']; +} + +export const SETTINGS_VISIBLE_SET = 'SETTINGS_VISIBLE/SET'; + +export interface SETTINGS_VISIBLE_SET_ACTION { + type: typeof SETTINGS_VISIBLE_SET; state: boolean; } @@ -43,4 +61,7 @@ export type USER_ACTIONS = | USER_BANLIST_SET_ACTION | USER_BAN_ACTION | USER_UNBAN_ACTION - | BLOCKED_VISIBLE_SET_ACTION; + | USER_HIDELIST_SET_ACTION + | USER_HIDE_ACTION + | USER_UNHIDE_ACTION + | SETTINGS_VISIBLE_SET_ACTION; diff --git a/frontend/app/utils/actionBinder.ts b/frontend/app/utils/actionBinder.ts new file mode 100644 index 00000000..145bb8dd --- /dev/null +++ b/frontend/app/utils/actionBinder.ts @@ -0,0 +1,23 @@ +import { StoreAction } from '@app/store'; +import { Action } from 'redux'; + +/** Helper type which is used to convert actionCreator to redux `connect` bound prop */ +export type BoundActionCreator = A extends (...args: infer U) => StoreAction + ? (...args: U) => R + : A extends (...args: infer U) => Action + ? (...args: U) => Action + : A extends (...args: infer U) => Promise + ? (...args: U) => Promise + : never; + +/** Helper type which is used to convert actionCreators map to redux `connect` bound props */ +export type BoundActionCreators = { [K in keyof T]: BoundActionCreator }; + +/** + * no-op function that is used for type conversion for action creators connected + * through mapDispatchToProps + */ +export function bindActions(obj: A): BoundActionCreators { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return obj as any; +} diff --git a/frontend/app/utils/ttl-to-time.ts b/frontend/app/utils/ttl-to-time.ts index 508bd266..425f4988 100644 --- a/frontend/app/utils/ttl-to-time.ts +++ b/frontend/app/utils/ttl-to-time.ts @@ -1,22 +1,25 @@ import { BlockTTL } from '@app/common/types'; -export function ttlToTime(ttl: BlockTTL): string { - const now = new Date(); - if (ttl === 'permanently') { - now.setFullYear(now.getFullYear() + 100); - return now.toISOString(); +export function ttlToDate(ttl: BlockTTL): Date { + const date = new Date(); + switch (ttl) { + case 'permanently': + date.setFullYear(date.getFullYear() + 100); + return date; + case '43200m': + date.setMonth(date.getMonth() + 1); + return date; + case '10080m': + date.setDate(date.getDate() + 7); + return date; + case '1440m': + date.setDate(date.getDate() + 1); + return date; + default: + throw new Error('unknown block ttl'); } - if (ttl === '43200m') { - now.setMonth(now.getMonth() + 1); - return now.toISOString(); - } - if (ttl === '10080m') { - now.setDate(now.getDate() + 7); - return now.toISOString(); - } - if (ttl === '1440m') { - now.setDate(now.getDate() + 1); - return now.toISOString(); - } - throw new Error('unknown block ttl'); +} + +export function ttlToTime(ttl: BlockTTL): string { + return ttlToDate(ttl).toISOString(); }