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
This commit is contained in:
Misha Vyrtsev
2019-06-10 19:20:39 -05:00
committed by Umputun
parent 133f5fc3f2
commit a302bdbe5d
38 changed files with 754 additions and 466 deletions
+3
View File
@@ -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';
@@ -12,6 +12,7 @@ const DefaultProps: Partial<Props> = {
url: 'https://example.com',
count: 3,
},
hiddenUsers: {},
};
describe('<AuthPanel />', () => {
@@ -65,6 +66,39 @@ describe('<AuthPanel />', () => {
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 = (
<AuthPanel
{...DefaultProps as Props}
user={null}
postInfo={{ ...DefaultProps.postInfo, read_only: true } as PostInfo}
/>
);
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 = (
<AuthPanel
{...DefaultProps as Props}
user={null}
postInfo={{ ...DefaultProps.postInfo, read_only: true } as PostInfo}
hiddenUsers={{ hidden_joe: {} as any }}
/>
);
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('<AuthPanel />', () => {
const adminAction = container.querySelector('.auth-panel__admin-action')!;
expect(adminAction.textContent).toEqual('Show blocked users');
expect(adminAction.textContent).toEqual('Show settings');
});
});
});
@@ -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<Props, State> {
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 (
<div className={b('auth-panel', {}, { theme: props.theme, loggedIn })}>
@@ -205,17 +209,17 @@ export class AuthPanel extends Component<Props, State> {
)}
<div className="auth-panel__column">
{user && user.admin && (
{isSettingsLabelVisible && (
<span
className="auth-panel__pseudo-link auth-panel__admin-action"
{...getHandleClickProps(() => this.toggleBlockedVisibility())}
role="link"
>
{isBlockedVisible ? 'Hide' : 'Show'} blocked users
{isBlockedVisible ? 'Hide' : 'Show'} settings
</span>
)}
{user && user.admin && ' • '}
{isSettingsLabelVisible && ' • '}
{user && user.admin && (
<span
@@ -1,6 +0,0 @@
.blocked-users__list-item_view_invisible {
.blocked-users__username {
opacity: 0.5;
text-decoration: line-through;
}
}
@@ -1,11 +0,0 @@
.blocked-users__list-item {
cursor: default;
@media (hover: hover) {
&:hover {
.blocked-users__action {
opacity: 1;
}
}
}
}
@@ -1,3 +0,0 @@
.blocked-users__list {
padding: 0 0 0 20px;
}
@@ -1,3 +0,0 @@
.blocked-users__username {
font-weight: 700;
}
@@ -1,11 +0,0 @@
.blocked-users_theme_dark {
.blocked-users__action {
&::before {
color: #ddd;
}
}
.blocked-users__user-id {
color: #eee;
}
}
@@ -1,11 +0,0 @@
.blocked-users_theme_light {
.blocked-users__action {
&::before {
color: #777;
}
}
.blocked-users__user-id {
color: #888;
}
}
@@ -1,103 +0,0 @@
/** @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';
interface Props {
theme: Theme;
users: BlockedUser[];
blockUser(id: User['id'], name: string, ttl: BlockTTL): Promise<void>;
unblockUser(id: User['id']): Promise<void>;
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<Props, State> {
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<Props>, { users, unblockedUsers }: State) {
return (
<div className={b('blocked-users', {}, { theme })} role="region" aria-label="Blocked users">
{!users.length && <p>There are no blocked users.</p>}
{!!users.length && <p>List of blocked users:</p>}
{!!users.length && (
<ul className="blocked-users__list">
{users.map(user => {
const isUserUnblocked = unblockedUsers.includes(user.id);
return (
<li className={b('blocked-users__list-item', {}, { view: isUserUnblocked ? 'invisible' : null })}>
<span className="blocked-users__username">{user.name}</span>{' '}
<span className="blocked-users__user-id">({user.id})</span>
<span className="blocked-users__user-block-ttl"> {formatTime(new Date(user.time))}</span>
{isUserUnblocked && (
<span {...getHandleClickProps(() => this.block(user))} className="blocked-users__action">
block
</span>
)}
{!isUserUnblocked && (
<span {...getHandleClickProps(() => this.unblock(user))} className="blocked-users__action">
unblock
</span>
)}
</li>
);
})}
</ul>
)}
</div>
);
}
}
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}`;
}
@@ -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');
@@ -174,20 +174,26 @@ describe('<Comment />', () => {
container = domContainer;
});
it('visible for admin', () => {
it('for admin if shows admin controls', () => {
const element = <Comment {...{ ...DefaultProps, user: { ...DefaultProps.user, admin: true } } as Props} />;
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 = <Comment {...{ ...DefaultProps, user: { ...DefaultProps.user, admin: false } } as Props} />;
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', () => {
+158 -135
View File
@@ -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<void>;
updateComment?: (id: CommentType['id'], text: string) => Promise<void>;
removeComment?(id: CommentType['id']): Promise<void>;
setReplyEditState?(id: CommentType['id'], mode: CommentMode): void;
getPreview?: (text: string) => Promise<string>;
putCommentVote?(id: CommentType['id'], value: number): Promise<void>;
setCollapse?: (id: CommentType['id'], value: boolean) => void;
setPinState?(id: CommentType['id'], value: boolean): Promise<void>;
blockUser?(id: User['id'], name: User['name'], ttl: BlockTTL): Promise<void>;
unblockUser?(id: User['id']): Promise<void>;
setVerifyStatus?(id: User['id'], value: boolean): Promise<void>;
uploadImage?(image: File): Promise<Image>;
}
} & Partial<typeof boundActions>;
export interface State {
isCopied: boolean;
@@ -105,7 +92,7 @@ export class Comment extends Component<Props, State> {
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<Props, State> {
});
}
}
}
};
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<Props, State> {
// 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<Props, State> {
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<Props, State> {
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<Props, State> {
});
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<Props, State> {
});
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<Props, State> {
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(`<b>${username}</b>&nbsp;${time}<br>${text.replace(/\n+/g, '<br>')}`);
@@ -296,42 +292,42 @@ export class Comment extends Component<Props, State> {
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<Props, State> {
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<Props, State> {
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 ? (
<span className="comment__control comment__control_view_inactive">Copied!</span>
) : (
<span {...getHandleClickProps(this.copyComment)} className="comment__control">
Copy
</span>
)
);
controls.push(
<span {...getHandleClickProps(this.togglePin)} className="comment__control">
{this.props.data.pin ? 'Unpin' : 'Pin'}
</span>
);
}
if (!isCurrentUser) {
controls.push(
<span {...getHandleClickProps(this.hideUser)} className="comment__control">
Hide
</span>
);
}
if (isAdmin) {
if (this.props.isUserBanned) {
controls.push(
<span {...getHandleClickProps(this.onUnblockUserClick)} className="comment__control">
Unblock
</span>
);
}
if (this.props.user!.id !== this.props.data.user.id && !this.props.isUserBanned) {
controls.push(
<span className="comment__control comment__control_select-label">
Block
<select className="comment__control_select" onBlur={this.onBlockUserClick} onChange={this.onBlockUserClick}>
<option disabled selected value={undefined}>
{' '}
Blocking period{' '}
</option>
{BLOCKING_DURATIONS.map(block => (
<option value={block.value}>{block.label}</option>
))}
</select>
</span>
);
}
if (!this.props.data.delete) {
controls.push(
<span {...getHandleClickProps(this.deleteComment)} className="comment__control">
Delete
</span>
);
}
}
return controls;
};
render(props: RenderableProps<Props>, state: State) {
const isAdmin = this.isAdmin();
@@ -371,6 +443,7 @@ export class Comment extends Component<Props, State> {
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, State> {
{props.view !== 'user' && (
<span
{...getHandleClickProps(() => this.toggleUserInfoVisibility())}
{...getHandleClickProps(this.toggleUserInfoVisibility)}
className="comment__username"
title={o.user.id}
>
@@ -484,7 +557,7 @@ export class Comment extends Component<Props, State> {
{isAdmin && props.view !== 'user' && (
<span
{...getHandleClickProps(() => 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, State> {
{!props.disabled && props.view === 'main' && (
<span
{...getHandleClickProps(() => 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<Props, State> {
{ 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<Props, State> {
{ 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, State> {
{(!props.collapsed || props.view === 'pinned') && (
<div className="comment__actions">
{!props.data.delete && !props.isCommentsDisabled && !props.disabled && !isGuest && props.view === 'main' && (
<span {...getHandleClickProps(() => this.toggleReplying())} className="comment__action">
<span {...getHandleClickProps(this.toggleReplying)} className="comment__action">
{isReplying ? 'Cancel' : 'Reply'}
</span>
)}
@@ -587,14 +660,14 @@ export class Comment extends Component<Props, State> {
(editable || isEditing) &&
props.view === 'main' && [
<span
{...getHandleClickProps(() => this.toggleEditing())}
{...getHandleClickProps(this.toggleEditing)}
className="comment__action comment__action_type_edit"
>
{isEditing ? 'Cancel' : 'Edit'}
</span>,
!isAdmin && (
<span
{...getHandleClickProps(() => this.deleteComment())}
{...getHandleClickProps(this.deleteComment)}
className="comment__action comment__action_type_delete"
>
Delete
@@ -613,57 +686,7 @@ export class Comment extends Component<Props, State> {
),
]}
{!props.data.delete && isAdmin && (
<span className="comment__controls">
{!state.isCopied && (
<span
{...getHandleClickProps(() => this.copyComment({ username: o.user.name, time: o.time }))}
className="comment__control"
>
Copy
</span>
)}
{state.isCopied && <span className="comment__control comment__control_view_inactive">Copied!</span>}
{(props.view === 'main' || props.view === 'pinned') && (
<span {...getHandleClickProps(() => this.setPin(!props.data.pin))} className="comment__control">
{props.data.pin ? 'Unpin' : 'Pin'}
</span>
)}
{props.isUserBanned && (
<span {...getHandleClickProps(() => this.onUnblockUserClick())} className="comment__control">
Unblock
</span>
)}
{props.user!.id !== props.data.user.id && !props.isUserBanned && (
<span className="comment__control comment__control_select-label">
Block
<select
className="comment__control_select"
onBlur={e => this.onBlockUserClick(e)}
onChange={e => this.onBlockUserClick(e)}
>
<option disabled selected value={undefined}>
{' '}
Blocking period{' '}
</option>
{BLOCKING_DURATIONS.map(block => (
<option value={block.value}>{block.label}</option>
))}
</select>
</span>
)}
{!props.data.delete && (
<span {...getHandleClickProps(() => this.deleteComment())} className="comment__control">
Delete
</span>
)}
</span>
)}
{commentControls.length > 0 && <span className="comment__controls">{commentControls}</span>}
</div>
)}
</div>
@@ -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<typeof boundActions>
)(Comment);
+56 -65
View File
@@ -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<Tree>;
fetchUser(): Promise<User | null>;
fetchBlockedUsers(): Promise<BlockedUser[]>;
logIn(p: AuthProvider): Promise<User | null>;
logOut(): Promise<void>;
setTheme: (theme: Theme) => void;
setBlockedVisible: (value: boolean) => boolean;
changeSort(sort: Sorting): Promise<void>;
enableComments(): Promise<boolean>;
disableComments(): Promise<boolean>;
getPreview(text: string): Promise<string>;
blockUser(id: User['id'], name: User['name'], ttl: BlockTTL): Promise<void>;
unblockUser(id: User['id']): Promise<void>;
addComment(text: string, title: string, pid?: CommentType['id']): Promise<void>;
updateComment(id: string, text: string): Promise<void>;
uploadImage(image: File): Promise<Image>;
}
hiddenUsers: StoreState['hiddenUsers'];
blockedUsers: BlockedUser[];
isSettingsVisible: boolean;
} & typeof boundActions;
interface State {
isLoaded: boolean;
@@ -159,21 +164,22 @@ export class Root extends Component<Props, State> {
}
}
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<Props, State> {
<AuthPanel
theme={this.props.theme}
user={this.props.user}
hiddenUsers={this.props.hiddenUsers}
sort={this.props.sort}
providers={StaticStore.config.auth_providers}
isCommentsDisabled={isCommentsDisabled}
@@ -234,7 +241,7 @@ export class Root extends Component<Props, State> {
onSortChange={this.props.changeSort}
/>
{!this.props.isBlockedVisible && (
{!this.props.isSettingsVisible && (
<div className="root__main">
{!isGuest && !isCommentsDisabled && (
<Input
@@ -284,12 +291,16 @@ export class Root extends Component<Props, State> {
</div>
)}
{this.props.isBlockedVisible && (
{this.props.isSettingsVisible && (
<div className="root__main">
<BlockedUsers
users={this.props.bannedUsers}
<Settings
user={this.props.user}
hiddenUsers={this.props.hiddenUsers}
blockedUsers={this.props.blockedUsers}
blockUser={this.props.blockUser}
unblockUser={this.props.unblockUser}
hideUser={this.props.hideUser}
unhideUser={this.props.unhideUser}
onUnblockSomeone={this.onUnblockSomeone}
/>
</div>
@@ -307,38 +318,18 @@ export class Root extends Component<Props, State> {
}
}
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);
@@ -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;
}
@@ -0,0 +1,3 @@
.settings__dimmed {
opacity: 0.5;
}
@@ -0,0 +1,3 @@
.settings__invisible {
opacity: 0.4;
}
@@ -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;
}
}
}
}
@@ -0,0 +1,3 @@
.settings__section + .settings__section {
margin-top: 2em;
}
@@ -0,0 +1,5 @@
.settings__user-id {
font-style: italic;
font-size: 0.8em;
word-break: break-all;
}
@@ -0,0 +1,3 @@
.settings__username {
font-weight: 700;
}
@@ -0,0 +1,11 @@
.settings_theme_dark {
.settings__action {
&::before {
color: #ddd;
}
}
.settings__blocked-users-username {
color: #eee;
}
}
@@ -0,0 +1,11 @@
.settings_theme_light {
.settings__action {
&::before {
color: #777;
}
}
.settings__blocked-users-username {
color: #888;
}
}
+16
View File
@@ -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');
@@ -1,3 +1,3 @@
.blocked-users {
.settings {
padding: 10px 0;
}
@@ -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<void>;
unblockUser(id: User['id']): Promise<void>;
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<Props, State> {
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<Props>, { blockedUsers, unblockedUsers, unhiddenUsers }: State) {
const hiddenUsersList = Object.values(this.state.hiddenUsers);
return (
<div className={b('settings', {}, { theme })}>
<div className="settings__section settings__hidden-users" role="region" aria-label="Hidden users">
<h3>Hidden users:</h3>
{!hiddenUsersList.length && <h4 className="settings__dimmed">There are no hidden users.</h4>}
{!!hiddenUsersList.length && (
<ul className="settings__list">
{hiddenUsersList.map(user => {
const isUserUnhidden = unhiddenUsers.includes(user.id);
return (
<li className="settings__list-item">
<span
className={['settings__username', isUserUnhidden ? 'settings__invisible' : null].join(' ')}
title={user.id}
>
{user.name || 'unknown'}
</span>
{this.__isUserHidden(user) ? (
<span className="settings__action" {...getHandleClickProps(() => this.unhide(user))}>
show
</span>
) : (
<span className="settings__action" {...getHandleClickProps(() => this.hide(user))}>
hide
</span>
)}
<div>
<span className="settings__user-id">
id: <span>{user.id}</span>
</span>
</div>
</li>
);
})}
</ul>
)}
</div>
{user && user.admin && (
<div className="settings__section settings__blocked-users" role="region" aria-label="Blocked users">
<h3>Blocked users:</h3>
{!blockedUsers.length && <h4 className="settings__dimmed">There are no blocked users.</h4>}
{!!blockedUsers.length && (
<ul className="settings__list settings__blocked-users-list">
{blockedUsers.map(user => {
const isUserUnblocked = unblockedUsers.includes(user.id);
return (
<li className="settings__list-item">
<span
className={['settings__username', isUserUnblocked ? 'settings__invisible' : null].join(' ')}
title={user.id}
>
{user.name || 'unknown'}
</span>
<span className="settings__blocked-users-user-block-ttl"> {formatTime(new Date(user.time))}</span>
{isUserUnblocked && (
<span {...getHandleClickProps(() => this.block(user))} className="blocked-users__action">
block
</span>
)}
{!isUserUnblocked && (
<span {...getHandleClickProps(() => this.unblock(user))} className="settings__action">
unblock
</span>
)}
<div>
<span className="settings__user-id">
id: <span>{user.id}</span>
</span>
</div>
</li>
);
})}
</ul>
)}
</div>
)}
</div>
);
}
}
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}`;
}
+1 -1
View File
@@ -31,7 +31,7 @@ function Thread(props: RenderableProps<Props>) {
role={['listitem'].concat(!collapsed && replies.length ? 'list' : []).join(' ')}
aria-expanded={!collapsed}
>
<Comment view="main" data={comment} repliesCount={replies.length} level={level} getPreview={props.getPreview} />
<Comment view="main" data={comment} repliesCount={replies.length} level={level} />
{!collapsed &&
!!replies.length &&
+6 -1
View File
@@ -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<void> {
return;
}
const boundFetchHiddenUsers = bindActionCreators(fetchHiddenUsers, reduxStore.dispatch);
boundFetchHiddenUsers();
const params = window.location.search
.replace(/^\?/, '')
.split('&')
@@ -63,7 +68,7 @@ async function init(): Promise<void> {
} else {
render(
<Provider store={reduxStore}>
<ConnectedRoot getPreview={api.getPreview} />
<ConnectedRoot />
</Provider>,
node.parentElement!,
node
+5 -1
View File
@@ -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<Promise<void>> =>
};
/** fetches comments from server */
export const fetchComments = (sort: Sorting): StoreAction<Promise<Tree>> => async dispatch => {
export const fetchComments = (sort: Sorting): StoreAction<Promise<Tree>> => 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,
+21
View File
@@ -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<Node[]>((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
+18 -4
View File
@@ -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<R> = ThunkAction<R, StoreState, undefined, ACTIONS>;
export type StoreAction<R, A extends AnyAction = ACTIONS> = ThunkAction<R, StoreState, undefined, A>;
/**
* Thunk Dispatch shortcut
*/
export type StoreDispatch = ThunkDispatch<StoreState, undefined, ACTIONS>;
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;
+2 -2
View File
@@ -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,
+50 -5
View File
@@ -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<Promise<User | null>> => async dispatch => {
const user = await api.getUser();
@@ -67,7 +82,37 @@ export const unblockUser = (id: User['id']): StoreAction<Promise<void>> => async
});
};
export const setVirifiedStatus = (id: User['id'], status: boolean): StoreAction<Promise<void>> => async (
export const fetchHiddenUsers = (): StoreAction<void> => dispatch => {
if (!IS_STORAGE_AVAILABLE) return;
const hiddenUsers = JSON.parse(getItem(LS_HIDDEN_USERS_KEY) || '{}');
return (dispatch as Dispatch<USER_HIDELIST_SET_ACTION>)({ type: USER_HIDELIST_SET, payload: hiddenUsers });
};
export const hideUser = (user: User): StoreAction<void> => (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<USER_HIDE_ACTION>)({ 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<void> => 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<USER_UNHIDE_ACTION>)({ type: USER_UNHIDE, id: userId });
};
export const setVerifiedStatus = (id: User['id'], status: boolean): StoreAction<Promise<void>> => 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<boolean> => dispatch => {
export const setSettingsVisibleState = (state: boolean): StoreAction<boolean> => dispatch => {
dispatch({
type: BLOCKED_VISIBLE_SET,
type: SETTINGS_VISIBLE_SET,
state,
});
return state;
+29 -7
View File
@@ -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 };
+28 -7
View File
@@ -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;
+23
View File
@@ -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> = A extends (...args: infer U) => StoreAction<infer R>
? (...args: U) => R
: A extends (...args: infer U) => Action<infer R>
? (...args: U) => Action<R>
: A extends (...args: infer U) => Promise<infer R>
? (...args: U) => Promise<R>
: never;
/** Helper type which is used to convert actionCreators map to redux `connect` bound props */
export type BoundActionCreators<T extends object> = { [K in keyof T]: BoundActionCreator<T[K]> };
/**
* no-op function that is used for type conversion for action creators connected
* through mapDispatchToProps
*/
export function bindActions<A extends object>(obj: A): BoundActionCreators<A> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return obj as any;
}
+21 -18
View File
@@ -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();
}