update comment actions block
This commit is contained in:
@@ -129,14 +129,7 @@ export interface Config {
|
||||
}
|
||||
|
||||
export type Sorting = '-time' | '+time' | '-active' | '+active' | '-score' | '+score' | '-controversy' | '+controversy';
|
||||
|
||||
export type BlockTTL = 'permanently' | '43200m' | '10080m' | '1440m';
|
||||
|
||||
export interface BlockingDuration {
|
||||
label: string;
|
||||
value: BlockTTL;
|
||||
}
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
||||
/**
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
.hollow {
|
||||
color: rgb(var(--primary-color));
|
||||
background-color: unset;
|
||||
text-transform: initial;
|
||||
@@ -87,6 +87,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
composes: hollow;
|
||||
border: 0;
|
||||
padding: 2px;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
color: rgb(var(--primary-brighter-color));
|
||||
}
|
||||
}
|
||||
|
||||
.link[disabled] {
|
||||
background-color: unset;
|
||||
color: rgba(var(--primary-color), 0.9);
|
||||
}
|
||||
|
||||
:global(.dark) {
|
||||
& .button {
|
||||
border-color: rgba(var(--white-color), 0.1);
|
||||
|
||||
@@ -5,7 +5,7 @@ import styles from './button.module.css';
|
||||
|
||||
type Props = Omit<JSX.HTMLAttributes<HTMLButtonElement>, 'size'> & {
|
||||
size?: 'xs' | 'sm';
|
||||
kind?: 'transparent' | 'link';
|
||||
kind?: 'transparent' | 'link' | 'hollow';
|
||||
suffix?: VNode;
|
||||
loading?: boolean;
|
||||
selected?: boolean;
|
||||
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
.comment__action_type_collapse {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 68px;
|
||||
box-sizing: border-box;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 10px;
|
||||
text-align: center;
|
||||
border: 1px solid;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color9);
|
||||
color: var(--color9);
|
||||
}
|
||||
|
||||
&.comment__action_selected {
|
||||
&:hover {
|
||||
background: var(--color9);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
.comment__action {
|
||||
font-size: 14px;
|
||||
vertical-align: middle;
|
||||
|
||||
& + .comment__action {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
& + .comment__controls {
|
||||
&::before {
|
||||
content: '•';
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
.comment__control_select {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
.comment__control_select-label {
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
font-weight: 700;
|
||||
|
||||
&::after {
|
||||
content: '▾';
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
.comment__control_view_inactive {
|
||||
&,
|
||||
&:hover,
|
||||
&:focus {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
.comment__control {
|
||||
margin-right: 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
.comment__controls {
|
||||
display: inline;
|
||||
vertical-align: middle;
|
||||
user-select: none;
|
||||
font-size: 14px;
|
||||
|
||||
@media (hover: hover) {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
.comment__edit-timer {
|
||||
font-size: 14px;
|
||||
vertical-align: middle;
|
||||
user-select: none;
|
||||
margin-left: 8px;
|
||||
|
||||
& + .comment__controls {
|
||||
&::before {
|
||||
content: '•';
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: rgb(var(--primary-color));
|
||||
}
|
||||
|
||||
.root > * + *,
|
||||
.additionalActions > * + * {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
color: rgb(var(--secondary-darker-text-color));
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.additionalActions {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.root:hover .additionalActions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.additionalActions::before {
|
||||
content: '•';
|
||||
color: rgb(var(--secondary-text-color));
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { h } from 'preact';
|
||||
import '@testing-library/jest-dom';
|
||||
import { CommentActions, Props } from './comment-actions';
|
||||
import { render } from 'tests/utils';
|
||||
import { screen, waitFor } from '@testing-library/preact';
|
||||
|
||||
function getProps(): Props {
|
||||
return {
|
||||
pinned: false,
|
||||
admin: false,
|
||||
currentUser: false,
|
||||
copied: false,
|
||||
bannedUser: false,
|
||||
readOnly: false,
|
||||
editing: false,
|
||||
replying: false,
|
||||
onCopy() {},
|
||||
onDelete() {},
|
||||
onTogglePin() {},
|
||||
onToggleReplying() {},
|
||||
onHideUser() {},
|
||||
onBlockUser() {},
|
||||
onUnblockUser() {},
|
||||
onDisableEditing() {},
|
||||
editable: false,
|
||||
editDeadline: undefined,
|
||||
};
|
||||
}
|
||||
describe('<CommentActions/>', () => {
|
||||
let props: Props;
|
||||
|
||||
beforeEach(() => {
|
||||
props = getProps();
|
||||
});
|
||||
|
||||
it('should render "Reply"', () => {
|
||||
render(<CommentActions {...props} />);
|
||||
expect(screen.getByText('Reply')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should not render "Reply" in read only mode', () => {
|
||||
props.readOnly = true;
|
||||
render(<CommentActions {...props} />);
|
||||
expect(screen.queryByText('Reply')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render "Cancel" instead "Reply" in replying mode', () => {
|
||||
props.replying = true;
|
||||
render(<CommentActions {...props} />);
|
||||
expect(screen.queryByText('Reply')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render "Hide" on comments not from currentUser', () => {
|
||||
props.currentUser = false;
|
||||
render(<CommentActions {...props} />);
|
||||
expect(screen.getByText('Hide')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should not render "Hide" on comments not from currentUser', () => {
|
||||
props.currentUser = true;
|
||||
render(<CommentActions {...props} />);
|
||||
expect(screen.queryByText('Hide')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render "Edit" and timer when editing is available', async () => {
|
||||
Object.assign(props, { editable: true, editDeadline: Date.now() + 300 * 1000 });
|
||||
render(<CommentActions {...props} />);
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||
await waitFor(() => expect(['300s', '299s']).toContain(screen.getByRole('timer').textContent));
|
||||
});
|
||||
|
||||
it('should render "Cancel" instead "Edit" in editing mode', async () => {
|
||||
Object.assign(props, { editable: true, editing: true, editDeadline: Date.now() + 300 * 1000 });
|
||||
render(<CommentActions {...props} />);
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ editable: false, editDeadline: Date.now() + 300 * 1000 }],
|
||||
[{ editable: true, editDeadline: undefined }],
|
||||
] as Partial<Props>[][])('should not render "Edit" when editing is not available', (override) => {
|
||||
Object.assign(props, override);
|
||||
render(<CommentActions {...props} />);
|
||||
expect(screen.getByText('Hide')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render "Delete" for current user comments', () => {
|
||||
props.currentUser = true;
|
||||
render(<CommentActions {...props} />);
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render "Delete" for other users comments', () => {
|
||||
render(<CommentActions {...props} />);
|
||||
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('admin actions', () => {
|
||||
it('should render "Copy"', () => {
|
||||
props.admin = true;
|
||||
render(<CommentActions {...props} />);
|
||||
expect(screen.getByText('Copy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render "Copied" when comment copied', () => {
|
||||
Object.assign(props, { admin: true, copied: true });
|
||||
render(<CommentActions {...props} />);
|
||||
expect(screen.getByText('Copied!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render "Pin"', () => {
|
||||
props.admin = true;
|
||||
render(<CommentActions {...props} />);
|
||||
expect(screen.getByText('Pin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render "Unpin" when comment is pinned', () => {
|
||||
Object.assign(props, { admin: true, pinned: true });
|
||||
render(<CommentActions {...props} />);
|
||||
expect(screen.getByText('Unpin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([[{ currentUser: false, admin: true }], [{ currentUser: true, admin: true }]] as Partial<Props>[][])(
|
||||
'should render "Delete" on all comments for admin',
|
||||
(override) => {
|
||||
Object.assign(props, override);
|
||||
render(<CommentActions {...props} />);
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
}
|
||||
);
|
||||
|
||||
it('should render admin actions in right order', () => {
|
||||
props.admin = true;
|
||||
render(<CommentActions {...props} />);
|
||||
expect(screen.getByTestId('comment-actions-additional').children[0]).toHaveTextContent('Hide');
|
||||
expect(screen.getByTestId('comment-actions-additional').children[1]).toHaveTextContent('Copy');
|
||||
expect(screen.getByTestId('comment-actions-additional').children[2]).toHaveTextContent('Pin');
|
||||
expect(screen.getByTestId('comment-actions-additional').children[3]).toHaveTextContent('Block');
|
||||
expect(screen.getByTestId('comment-actions-additional').children[4]).toHaveTextContent('Delete');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import clsx from 'clsx';
|
||||
import { h, Fragment } from 'preact';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { BlockTTL } from 'common/types';
|
||||
import { Select } from 'components/select';
|
||||
import { Countdown } from 'components/countdown';
|
||||
import { Button } from 'components/auth/components/button';
|
||||
|
||||
import { getBlockingDurations } from './getBlockingDurations';
|
||||
import styles from './comment-actions.module.css';
|
||||
|
||||
export type Props = {
|
||||
admin: boolean | undefined;
|
||||
currentUser: boolean | undefined;
|
||||
pinned: boolean | undefined;
|
||||
copied: boolean | undefined;
|
||||
bannedUser: boolean | undefined;
|
||||
readOnly: boolean | undefined;
|
||||
editing: boolean | undefined;
|
||||
replying: boolean | undefined;
|
||||
editable: boolean;
|
||||
editDeadline: number | undefined;
|
||||
onCopy(): void;
|
||||
onDelete(): void;
|
||||
onTogglePin(): void;
|
||||
onToggleReplying(): void;
|
||||
onHideUser(): void;
|
||||
onBlockUser(ttl: BlockTTL): void;
|
||||
onUnblockUser(): void;
|
||||
onDisableEditing(): void;
|
||||
};
|
||||
|
||||
export function CommentActions({
|
||||
admin,
|
||||
pinned,
|
||||
copied,
|
||||
readOnly,
|
||||
editable,
|
||||
editing,
|
||||
replying,
|
||||
currentUser,
|
||||
bannedUser,
|
||||
editDeadline,
|
||||
onCopy,
|
||||
onDelete,
|
||||
onTogglePin,
|
||||
onToggleReplying,
|
||||
onDisableEditing,
|
||||
onHideUser,
|
||||
onBlockUser,
|
||||
onUnblockUser,
|
||||
}: Props) {
|
||||
const intl = useIntl();
|
||||
|
||||
const deleteJSX = (
|
||||
<Button kind="link" size="sm" onClick={onDelete}>
|
||||
{intl.formatMessage(messages.delete)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx('comment-actions', styles.root)}>
|
||||
{!readOnly && (
|
||||
<Button kind="link" size="sm" onClick={onToggleReplying}>
|
||||
{intl.formatMessage(replying ? messages.cancel : messages.reply)}
|
||||
</Button>
|
||||
)}
|
||||
{editable && editDeadline && (
|
||||
<>
|
||||
<Button kind="link" size="sm">
|
||||
{intl.formatMessage(editing ? messages.cancel : messages.edit)}
|
||||
</Button>
|
||||
<span
|
||||
role="timer"
|
||||
title={intl.formatMessage(messages.editCountdown)}
|
||||
className={clsx('comment-actions-countdown', styles.countdown)}
|
||||
>
|
||||
<Countdown timestamp={editDeadline} onTimePassed={onDisableEditing} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
data-testid="comment-actions-additional"
|
||||
className={clsx('comment-actions-additional', styles.additionalActions)}
|
||||
>
|
||||
{!currentUser && (
|
||||
<Button kind="link" size="sm" onClick={onHideUser}>
|
||||
{intl.formatMessage(messages.hide)}
|
||||
</Button>
|
||||
)}
|
||||
{admin && (
|
||||
<>
|
||||
<Button kind="link" size="sm" onClick={onCopy} disabled={copied}>
|
||||
{intl.formatMessage(copied ? messages.copied : messages.copy)}
|
||||
</Button>
|
||||
<Button kind="link" size="sm" onClick={onTogglePin}>
|
||||
{intl.formatMessage(pinned ? messages.unpin : messages.pin)}
|
||||
</Button>
|
||||
{bannedUser ? (
|
||||
<Button kind="link" size="sm" onClick={onUnblockUser}>
|
||||
{intl.formatMessage(messages.unblock)}
|
||||
</Button>
|
||||
) : (
|
||||
<Select
|
||||
title={intl.formatMessage(messages.blockingPeriod)}
|
||||
size="sm"
|
||||
items={getBlockingDurations(intl)}
|
||||
onChange={(evt) => onBlockUser(evt.currentTarget.value as BlockTTL)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{(currentUser || admin) && deleteJSX}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
unblock: { id: 'comment.unblock', defaultMessage: 'Unblock' },
|
||||
pin: { id: 'comment.pin', defaultMessage: 'Pin' },
|
||||
unpin: { id: 'comment.unpin', defaultMessage: 'Unpin' },
|
||||
hide: { id: 'comment.hide', defaultMessage: 'Hide' },
|
||||
cancel: { id: 'comment.cancel', defaultMessage: 'Cancel' },
|
||||
edit: { id: 'comment.edit', defaultMessage: 'Edit' },
|
||||
reply: { id: 'comment.reply', defaultMessage: 'Reply' },
|
||||
delete: { id: 'comment.delete', defaultMessage: 'Delete' },
|
||||
editCountdown: { id: 'comment.edit-countdown', defaultMessage: 'Edit will be disabled' },
|
||||
copied: { id: 'comment.copied', defaultMessage: 'Copied!' },
|
||||
copy: { id: 'comment.copy', defaultMessage: 'Copy' },
|
||||
blockingPeriod: { id: 'comment.blocking-period', defaultMessage: 'Blocking period' },
|
||||
});
|
||||
@@ -1,27 +1,27 @@
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user > * + * {
|
||||
margin-left: 4px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.verificationButton {
|
||||
display:flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.verificationIcon {
|
||||
color: var(--color29);
|
||||
color: var(--color29);
|
||||
}
|
||||
|
||||
.verificationIconInactive {
|
||||
color: var(--color1);
|
||||
transition: opacity 0.15s;
|
||||
color: var(--color1);
|
||||
transition: opacity 0.15s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.75;
|
||||
|
||||
@@ -1,33 +1,17 @@
|
||||
import { h } from 'preact';
|
||||
import { mount } from 'enzyme';
|
||||
import '@testing-library/jest-dom';
|
||||
import { screen } from '@testing-library/preact';
|
||||
import { useIntl, IntlShape } from 'react-intl';
|
||||
|
||||
import { render } from 'tests/utils';
|
||||
|
||||
import { useIntl, IntlProvider, IntlShape } from 'react-intl';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import enMessages from 'locales/en.json';
|
||||
import { StaticStore } from 'common/static-store';
|
||||
|
||||
import { Comment, CommentProps } from './comment';
|
||||
import { mockStore } from '__stubs__/store';
|
||||
|
||||
function CommentWithIntl(props: CommentProps) {
|
||||
return <Comment {...props} intl={useIntl()} />;
|
||||
}
|
||||
|
||||
// @depricated
|
||||
function mountComment(props: CommentProps) {
|
||||
return mount(
|
||||
<IntlProvider locale="en" messages={enMessages}>
|
||||
<Provider store={mockStore({})}>
|
||||
<CommentWithIntl {...props} />
|
||||
</Provider>
|
||||
</IntlProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function getProps(): CommentProps {
|
||||
return {
|
||||
isCommentsDisabled: false,
|
||||
@@ -184,58 +168,66 @@ describe('<Comment />', () => {
|
||||
expect(screen.queryByText('Votes score')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
describe('admin controls', () => {
|
||||
it('for admin if shows admin controls', () => {
|
||||
props.user!.admin = true;
|
||||
const element = mountComment(props);
|
||||
const controls = element.find('.comment__controls').children();
|
||||
expect(controls.length).toBe(5);
|
||||
expect(controls.at(0).text()).toEqual('Copy');
|
||||
expect(controls.at(1).text()).toEqual('Pin');
|
||||
expect(controls.at(2).text()).toEqual('Hide');
|
||||
expect(controls.at(3).getDOMNode().childNodes[0].textContent).toEqual('Block');
|
||||
expect(controls.at(4).text()).toEqual('Delete');
|
||||
|
||||
it('should render action buttons', () => {
|
||||
render(<CommentWithIntl {...props} />);
|
||||
expect(screen.getByText('Reply')).toBeVisible();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'pinned',
|
||||
() => {
|
||||
props.view = 'pinned';
|
||||
},
|
||||
],
|
||||
[
|
||||
'deleted',
|
||||
() => {
|
||||
props.data.delete = true;
|
||||
},
|
||||
],
|
||||
[
|
||||
'collapsed',
|
||||
() => {
|
||||
props.collapsed = true;
|
||||
},
|
||||
],
|
||||
])('should not render actions when comment is %s', (_, mutateProps) => {
|
||||
mutateProps();
|
||||
render(<CommentWithIntl {...props} />);
|
||||
expect(screen.queryByTitle('Reply')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should be editable', async () => {
|
||||
StaticStore.config.edit_duration = 300;
|
||||
|
||||
props.repliesCount = 0;
|
||||
props.user!.id = '100';
|
||||
props.data.user.id = '100';
|
||||
Object.assign(props.data, {
|
||||
id: '101',
|
||||
vote: 1,
|
||||
time: Date.now(),
|
||||
delete: false,
|
||||
orig: 'test',
|
||||
});
|
||||
|
||||
it('for regular user it shows only "hide"', () => {
|
||||
const element = mountComment(props);
|
||||
render(<CommentWithIntl {...props} />);
|
||||
expect(screen.getByText('Edit')).toBeVisible();
|
||||
});
|
||||
|
||||
const controls = element.find('.comment__controls').children();
|
||||
expect(controls.length).toBe(1);
|
||||
expect(controls.at(0).text()).toEqual('Hide');
|
||||
it('should not be editable', () => {
|
||||
StaticStore.config.edit_duration = 300;
|
||||
Object.assign(props.data, {
|
||||
user: props.user,
|
||||
id: '100',
|
||||
vote: 1,
|
||||
time: new Date(new Date().getDate() - 300).toString(),
|
||||
orig: 'test',
|
||||
});
|
||||
|
||||
it('should be editable', async () => {
|
||||
StaticStore.config.edit_duration = 300;
|
||||
|
||||
props.repliesCount = 0;
|
||||
props.user!.id = '100';
|
||||
props.data.user.id = '100';
|
||||
Object.assign(props.data, {
|
||||
id: '101',
|
||||
vote: 1,
|
||||
time: new Date().toString(),
|
||||
delete: false,
|
||||
orig: 'test',
|
||||
});
|
||||
|
||||
render(<CommentWithIntl {...props} />);
|
||||
// it can be less than 300 due to test checks time
|
||||
expect(['299', '300']).toContain(screen.getByRole('timer').innerText);
|
||||
});
|
||||
|
||||
it('should not be editable', () => {
|
||||
StaticStore.config.edit_duration = 300;
|
||||
Object.assign(props.data, {
|
||||
user: props.user,
|
||||
id: '100',
|
||||
vote: 1,
|
||||
time: new Date(new Date().getDate() - 300).toString(),
|
||||
orig: 'test',
|
||||
});
|
||||
|
||||
const component = mountComment(props);
|
||||
expect(component.find('Comment').state('editDeadline')).toBe(null);
|
||||
});
|
||||
render(<CommentWithIntl {...props} />);
|
||||
expect(screen.queryByRole('timer')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { FormattedMessage, IntlShape, defineMessages } from 'react-intl';
|
||||
import b from 'bem-react-helper';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { getHandleClickProps } from 'common/accessibility';
|
||||
import { COMMENT_NODE_CLASSNAME_PREFIX } from 'common/constants';
|
||||
|
||||
import { StaticStore } from 'common/static-store';
|
||||
@@ -14,14 +13,13 @@ import { isUserAnonymous } from 'utils/isUserAnonymous';
|
||||
|
||||
import { CommentFormProps } from 'components/comment-form';
|
||||
import { Avatar } from 'components/avatar';
|
||||
import { Button } from 'components/button';
|
||||
import { Countdown } from 'components/countdown';
|
||||
import { VerificationIcon } from 'components/icons/verification';
|
||||
import { getPreview, uploadImage } from 'common/api';
|
||||
import { postMessageToParent } from 'utils/post-message';
|
||||
import { getBlockingDurations } from './getBlockingDurations';
|
||||
import { boundActions } from './connected-comment';
|
||||
import { CommentVotes } from './comment-votes';
|
||||
import { CommentActions } from './comment-actions';
|
||||
|
||||
import styles from './comment.module.css';
|
||||
import './styles';
|
||||
@@ -59,7 +57,7 @@ export type CommentProps = {
|
||||
export interface State {
|
||||
renderDummy: boolean;
|
||||
isCopied: boolean;
|
||||
editDeadline: Date | null;
|
||||
editDeadline?: number;
|
||||
initial: boolean;
|
||||
}
|
||||
|
||||
@@ -68,6 +66,34 @@ export class Comment extends Component<CommentProps, State> {
|
||||
/** comment text node. Used in comment text copying */
|
||||
textNode = createRef<HTMLDivElement>();
|
||||
|
||||
/**
|
||||
* Defines whether current client is admin
|
||||
*/
|
||||
isAdmin = (): boolean => {
|
||||
return Boolean(this.props.user?.admin);
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines whether current client is not logged in
|
||||
*/
|
||||
isGuest = (): boolean => {
|
||||
return this.props.user === null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines whether current client is logged in via `Anonymous provider`
|
||||
*/
|
||||
isAnonymous = (): boolean => {
|
||||
return isUserAnonymous(this.props.user);
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines whether comment made by logged in user
|
||||
*/
|
||||
isCurrentUser = (): boolean => {
|
||||
return !this.isGuest() && this.props.data.user.id === this.props.user?.id;
|
||||
};
|
||||
|
||||
updateState = (props: CommentProps) => {
|
||||
const newState: Partial<State> = {};
|
||||
|
||||
@@ -76,16 +102,12 @@ export class Comment extends Component<CommentProps, State> {
|
||||
}
|
||||
|
||||
// set comment edit timer
|
||||
if (props.user && props.user.id === props.data.user.id) {
|
||||
if (this.isCurrentUser()) {
|
||||
const editDuration = StaticStore.config.edit_duration;
|
||||
const timeDiff = StaticStore.serverClientTimeDiff || 0;
|
||||
const editDeadline = new Date(new Date(props.data.time).getTime() + timeDiff + editDuration * 1000);
|
||||
const editDeadline = new Date(props.data.time).getTime() + timeDiff + editDuration * 1000;
|
||||
|
||||
if (editDeadline < new Date()) {
|
||||
newState.editDeadline = null;
|
||||
} else {
|
||||
newState.editDeadline = editDeadline;
|
||||
}
|
||||
newState.editDeadline = editDeadline > Date.now() ? editDeadline : undefined;
|
||||
}
|
||||
|
||||
return newState;
|
||||
@@ -94,18 +116,12 @@ export class Comment extends Component<CommentProps, State> {
|
||||
state = {
|
||||
renderDummy: typeof this.props.inView === 'boolean' ? !this.props.inView : false,
|
||||
isCopied: false,
|
||||
editDeadline: null,
|
||||
editDeadline: undefined,
|
||||
voteErrorMessage: null,
|
||||
initial: true,
|
||||
...this.updateState(this.props),
|
||||
};
|
||||
|
||||
// getHandleClickProps = (handler?: (e: KeyboardEvent | MouseEvent) => void) => {
|
||||
// if (this.state.initial) return null;
|
||||
// if (this.props.inView === false) return null;
|
||||
// return getHandleClickProps(handler);
|
||||
// };
|
||||
|
||||
componentWillReceiveProps(nextProps: CommentProps) {
|
||||
this.setState(this.updateState(nextProps));
|
||||
}
|
||||
@@ -152,26 +168,21 @@ export class Comment extends Component<CommentProps, State> {
|
||||
const userId = this.props.data.user.id;
|
||||
const intl = this.props.intl;
|
||||
const userName = this.props.data.user.name;
|
||||
const promptMessage = value
|
||||
? intl.formatMessage(messages.verifyUser, { userName })
|
||||
: intl.formatMessage(messages.unverifyUser, { userName });
|
||||
const promptMessage = intl.formatMessage(value ? messages.verifyUser : messages.unverifyUser, { userName });
|
||||
|
||||
if (window.confirm(promptMessage)) {
|
||||
this.props.setVerifiedStatus!(userId, value);
|
||||
}
|
||||
};
|
||||
|
||||
onBlockUserClick = (e: Event) => {
|
||||
const target = e.target as HTMLOptionElement;
|
||||
// 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
|
||||
if (e.type === 'change') {
|
||||
target.blur();
|
||||
onBlockUserClick = (evt: Event) => {
|
||||
const target = evt.currentTarget;
|
||||
|
||||
if (target instanceof HTMLOptionElement) {
|
||||
// we have to debounce the blockUser function calls otherwise it will be
|
||||
// called 2 times (by change event and by blur event)
|
||||
this.blockUser(target.value as BlockTTL);
|
||||
}
|
||||
// we have to debounce the blockUser function calls otherwise it will be
|
||||
// called 2 times (by change event and by blur event)
|
||||
this.blockUser(target.value as BlockTTL);
|
||||
};
|
||||
|
||||
blockUser = debounce((ttl: BlockTTL): void => {
|
||||
@@ -192,7 +203,7 @@ export class Comment extends Component<CommentProps, State> {
|
||||
}
|
||||
}, 100);
|
||||
|
||||
onUnblockUserClick = () => {
|
||||
unblockUser = () => {
|
||||
const { user } = this.props.data;
|
||||
const unblockUser = this.props.intl.formatMessage(messages.unblockUser);
|
||||
|
||||
@@ -277,116 +288,6 @@ export class Comment extends Component<CommentProps, State> {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines whether current client is admin
|
||||
*/
|
||||
isAdmin = (): boolean => {
|
||||
return !!this.props.user && this.props.user.admin;
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines whether current client is not logged in
|
||||
*/
|
||||
isGuest = (): boolean => {
|
||||
return !this.props.user;
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines whether current client is logged in via `Anonymous provider`
|
||||
*/
|
||||
isAnonymous = (): boolean => {
|
||||
return isUserAnonymous(this.props.user);
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines whether comment made by logged in user
|
||||
*/
|
||||
isCurrentUser = (): boolean => {
|
||||
if (this.isGuest()) return false;
|
||||
|
||||
return this.props.data.user.id === this.props.user!.id;
|
||||
};
|
||||
|
||||
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 comment__action">
|
||||
<FormattedMessage id="comment.copied" defaultMessage="Copied!" />
|
||||
</span>
|
||||
) : (
|
||||
<Button kind="link" onClick={this.copyComment} mix={['comment__control', 'comment__action']}>
|
||||
<FormattedMessage id="comment.copy" defaultMessage="Copy" />
|
||||
</Button>
|
||||
),
|
||||
<Button kind="link" onClick={this.togglePin} mix={['comment__control', 'comment__action']}>
|
||||
{this.props.data.pin ? (
|
||||
<FormattedMessage id="comment.unpin" defaultMessage="Unpin" />
|
||||
) : (
|
||||
<FormattedMessage id="comment.pin" defaultMessage="Pin" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isCurrentUser) {
|
||||
controls.push(
|
||||
<Button kind="link" onClick={this.hideUser} mix={['comment__control', 'comment__action']}>
|
||||
<FormattedMessage id="comment.hide" defaultMessage="Hide" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
if (this.props.isUserBanned) {
|
||||
controls.push(
|
||||
<Button kind="link" onClick={this.onUnblockUserClick} mix={['comment__control', 'comment__action']}>
|
||||
<FormattedMessage id="comment.unblock" defaultMessage="Unblock" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
const blockingDurations = getBlockingDurations(this.props.intl);
|
||||
if (this.props.user!.id !== this.props.data.user.id && !this.props.isUserBanned) {
|
||||
controls.push(
|
||||
<span className="comment__control comment__control_select-label">
|
||||
<FormattedMessage id="comment.block" defaultMessage="Block" />
|
||||
<select className="comment__control_select" onBlur={this.onBlockUserClick} onChange={this.onBlockUserClick}>
|
||||
<option disabled selected value={undefined}>
|
||||
<FormattedMessage id="comment.blocking-period" defaultMessage="Blocking period" />
|
||||
</option>
|
||||
{blockingDurations.map((block) => (
|
||||
<option key={block.value} value={block.value}>
|
||||
{block.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.props.data.delete) {
|
||||
controls.push(
|
||||
<Button kind="link" onClick={this.deleteComment} mix={['comment__control', 'comment__action']}>
|
||||
<FormattedMessage id="comment.delete" defaultMessage="Delete" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
return controls;
|
||||
};
|
||||
|
||||
render(props: CommentProps, state: State): JSX.Element {
|
||||
const isAdmin = this.isAdmin();
|
||||
const isGuest = this.isGuest();
|
||||
@@ -394,9 +295,7 @@ export class Comment extends Component<CommentProps, State> {
|
||||
|
||||
const isReplying = props.editMode === CommentMode.Reply;
|
||||
const isEditing = props.editMode === CommentMode.Edit;
|
||||
const editable = props.repliesCount === 0 && state.editDeadline;
|
||||
const uploadImageHandler = this.isAnonymous() ? undefined : this.props.uploadImage;
|
||||
const commentControls = this.getCommentControls();
|
||||
const intl = props.intl;
|
||||
const CommentForm = this.props.CommentForm || null;
|
||||
|
||||
@@ -580,61 +479,27 @@ export class Comment extends Component<CommentProps, State> {
|
||||
/>
|
||||
)}
|
||||
|
||||
{(!props.collapsed || props.view === 'pinned') && (
|
||||
<div className="comment__actions">
|
||||
{!props.data.delete && !props.isCommentsDisabled && !props.disabled && props.view === 'main' && (
|
||||
<Button kind="link" onClick={this.toggleReplying} mix="comment__action">
|
||||
{isReplying ? (
|
||||
<FormattedMessage id="comment.cancel" defaultMessage="Cancel" />
|
||||
) : (
|
||||
<FormattedMessage id="comment.reply" defaultMessage="Reply" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!props.data.delete &&
|
||||
!props.disabled &&
|
||||
!!o.orig &&
|
||||
isCurrentUser &&
|
||||
(editable || isEditing) &&
|
||||
props.view === 'main' && [
|
||||
<Button
|
||||
key="edit-button"
|
||||
kind="link"
|
||||
{...getHandleClickProps(this.toggleEditing)}
|
||||
mix={['comment__action', 'comment__action_type_edit']}
|
||||
>
|
||||
{isEditing ? (
|
||||
<FormattedMessage id="comment.cancel" defaultMessage="Cancel" />
|
||||
) : (
|
||||
<FormattedMessage id="comment.edit" defaultMessage="Edit" />
|
||||
)}
|
||||
</Button>,
|
||||
!isAdmin && (
|
||||
<Button
|
||||
key="delete-button"
|
||||
kind="link"
|
||||
{...getHandleClickProps(this.deleteComment)}
|
||||
mix={['comment__action', 'comment__action_type_delete']}
|
||||
>
|
||||
<FormattedMessage id="comment.delete" defaultMessage="Delete" />
|
||||
</Button>
|
||||
),
|
||||
state.editDeadline && (
|
||||
<Countdown
|
||||
key="countdown"
|
||||
className="comment__edit-timer"
|
||||
time={state.editDeadline}
|
||||
onTimePassed={() =>
|
||||
this.setState({
|
||||
editDeadline: null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
]}
|
||||
|
||||
{commentControls.length > 0 && <span className="comment__controls">{commentControls}</span>}
|
||||
</div>
|
||||
{(!props.collapsed || !this.props.data.delete) && props.view !== 'pinned' && (
|
||||
<CommentActions
|
||||
admin={isAdmin}
|
||||
pinned={props.data.pin}
|
||||
copied={state.isCopied}
|
||||
editing={isEditing}
|
||||
replying={isReplying}
|
||||
editable={props.repliesCount === 0 && state.editDeadline !== undefined}
|
||||
editDeadline={state.editDeadline}
|
||||
readOnly={props.post_info?.read_only}
|
||||
onToggleReplying={this.toggleReplying}
|
||||
onDisableEditing={() => this.setState({ editDeadline: undefined })}
|
||||
currentUser={isCurrentUser}
|
||||
bannedUser={props.isUserBanned}
|
||||
onCopy={this.copyComment}
|
||||
onTogglePin={this.togglePin}
|
||||
onDelete={this.deleteComment}
|
||||
onHideUser={this.hideUser}
|
||||
onBlockUser={this.blockUser}
|
||||
onUnblockUser={this.unblockUser}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -667,7 +532,7 @@ export class Comment extends Component<CommentProps, State> {
|
||||
onSubmit={(text: string) => this.updateComment(props.data.id, text)}
|
||||
onCancel={this.toggleEditing}
|
||||
getPreview={this.props.getPreview!}
|
||||
errorMessage={state.editDeadline === null ? intl.formatMessage(messages.expiredTime) : undefined}
|
||||
errorMessage={state.editDeadline === undefined ? intl.formatMessage(messages.expiredTime) : undefined}
|
||||
autofocus={true}
|
||||
uploadImage={uploadImageHandler}
|
||||
simpleView={StaticStore.config.simple_view}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { BlockingDuration } from 'common/types';
|
||||
import { IntlShape, defineMessages } from 'react-intl';
|
||||
import { defineMessages, IntlShape } from 'react-intl';
|
||||
import { BlockTTL } from 'common/types';
|
||||
|
||||
export interface BlockingDuration {
|
||||
label: string;
|
||||
value: BlockTTL | undefined;
|
||||
}
|
||||
|
||||
const blockingMessages = defineMessages({
|
||||
block: {
|
||||
id: 'comment.block',
|
||||
defaultMessage: 'Block',
|
||||
},
|
||||
permanently: {
|
||||
id: 'blockingDuration.permanently',
|
||||
defaultMessage: 'Permanently',
|
||||
@@ -22,6 +31,10 @@ const blockingMessages = defineMessages({
|
||||
|
||||
export function getBlockingDurations(intl: IntlShape): BlockingDuration[] {
|
||||
return [
|
||||
{
|
||||
label: intl.formatMessage(blockingMessages.block),
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage(blockingMessages.permanently),
|
||||
value: 'permanently',
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
import 'components/raw-content';
|
||||
|
||||
import './comment.css';
|
||||
|
||||
import './__action/comment__action.css';
|
||||
import './__action/_type/_collapse/comment__action_type_collapse.css';
|
||||
|
||||
import './__edit-timer/comment__edit-timer.css';
|
||||
|
||||
import './__body/comment__body.css';
|
||||
|
||||
import './__control/comment__control.css';
|
||||
import './__control/_select/comment__control_select.css';
|
||||
import './__control/_select-label/comment__control_select-label.css';
|
||||
import './__control/_view/_inactive/comment__control_view_inactive.css';
|
||||
|
||||
import './__controls/comment__controls.css';
|
||||
import './__info/comment__info.css';
|
||||
import './__input/comment__input.css';
|
||||
import './__link-to-parent/comment__link-to-parent.css';
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
type Props = {
|
||||
timestamp?: number;
|
||||
onTimePassed?: () => void;
|
||||
};
|
||||
|
||||
export function Countdown({ timestamp = 0, onTimePassed }: Props) {
|
||||
const [value, setValue] = useState(calcRestTime(timestamp));
|
||||
const intervalIdRef = useRef<number | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!timestamp) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => setValue(calcRestTime(timestamp || 0)), 1000);
|
||||
intervalIdRef.current = intervalId;
|
||||
|
||||
setValue(calcRestTime(timestamp || 0));
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [timestamp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value === 0) {
|
||||
onTimePassed?.();
|
||||
window.clearInterval(intervalIdRef.current);
|
||||
}
|
||||
}, [value, onTimePassed]);
|
||||
|
||||
if (!timestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Fragment>{value}s</Fragment>;
|
||||
}
|
||||
|
||||
function calcRestTime(timestamp: number): number {
|
||||
return Math.ceil(Math.max(0, (timestamp - Date.now()) / 1000));
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { h, JSX, Component, createRef } from 'preact';
|
||||
import { exclude } from 'utils/exclude';
|
||||
|
||||
type Props = {
|
||||
time: Date;
|
||||
onTimePassed?: () => void;
|
||||
} & JSX.HTMLAttributes;
|
||||
|
||||
interface State {
|
||||
/** props.time converted to timestamp */
|
||||
time: number;
|
||||
}
|
||||
|
||||
/** Component which uses plain DOM mutation instead of rerendering react reactive reactivity */
|
||||
export class Countdown extends Component<Props, State> {
|
||||
elemRef = createRef<HTMLSpanElement>();
|
||||
intervalID?: number;
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
time: props.time.getTime(),
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
this.start();
|
||||
}
|
||||
componentWillReceiveProps(nextProps: Props) {
|
||||
if (nextProps.time === this.props.time) return;
|
||||
this.setState({
|
||||
time: nextProps.time.getTime(),
|
||||
});
|
||||
this.start();
|
||||
}
|
||||
componentWillUnmount() {
|
||||
window.clearInterval(this.intervalID);
|
||||
}
|
||||
shouldComponentUpdate() {
|
||||
return false;
|
||||
}
|
||||
tick() {
|
||||
if (this.elemRef) {
|
||||
const value = Math.max(0, (this.state.time - new Date().getTime()) / 1000).toFixed(0);
|
||||
this.elemRef.current!.innerText = value;
|
||||
if (value === '0') {
|
||||
this.props.onTimePassed && this.props.onTimePassed();
|
||||
window.clearInterval(this.intervalID);
|
||||
this.intervalID = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
start() {
|
||||
if (this.intervalID) clearInterval(this.intervalID);
|
||||
this.tick();
|
||||
this.intervalID = window.setInterval(() => {
|
||||
this.tick();
|
||||
}, 1000);
|
||||
}
|
||||
render(props: Props) {
|
||||
return <span role="timer" {...exclude(props, 'time', 'onTimePassed')} ref={this.elemRef} />;
|
||||
}
|
||||
}
|
||||
@@ -215,7 +215,7 @@ export function Profile() {
|
||||
</section>
|
||||
{isCurrent ? (
|
||||
<footer className={clsx('profile-footer', styles.footer)}>
|
||||
<Button kind="link" size="sm" onClick={handleClickRequestRemoveData}>
|
||||
<Button kind="hollow" size="sm" onClick={handleClickRequestRemoveData}>
|
||||
<FormattedMessage id="profile.request-to-delete-data" defaultMessage="Request my data removal" />
|
||||
</Button>
|
||||
</footer>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.root__pinned-comments {
|
||||
margin-top: 20px;
|
||||
padding: 8px 12px;
|
||||
padding: 0 12px 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
border-radius: 2px;
|
||||
font-weight: bold;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sm {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rootFocused {
|
||||
|
||||
@@ -8,29 +8,48 @@ import styles from './select.module.css';
|
||||
|
||||
type Item = {
|
||||
label: string | number;
|
||||
value: string | number;
|
||||
value: string | number | undefined;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
type Props = Omit<
|
||||
JSX.HTMLAttributes<HTMLSelectElement>,
|
||||
'className' | 'onFocus' | 'onBlur' | 'selected' | 'label' | 'icon' | 'size'
|
||||
> & {
|
||||
size?: 'sm' | 'md';
|
||||
items: Item[];
|
||||
selected: Item;
|
||||
} & Omit<JSX.HTMLAttributes<HTMLSelectElement>, 'className' | 'onFocus' | 'onBlur' | 'selected'>;
|
||||
selected?: Item;
|
||||
};
|
||||
|
||||
export function Select({ items, selected, ...props }: Props) {
|
||||
export function Select({ items, selected, size = 'md', ...props }: Props) {
|
||||
const [focus, setFocus] = useState(false);
|
||||
const selectedItem = selected ?? items[0];
|
||||
|
||||
const iconSize = {
|
||||
sm: 10,
|
||||
md: 12,
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={clsx('select', styles.root, { [styles.rootFocused]: focus, select_focused: focus })}>
|
||||
{selected.label}
|
||||
<ArrowIcon className={clsx('select-arrow', styles.arrow)} />
|
||||
<span
|
||||
className={clsx('select', styles.root, size && styles[size], {
|
||||
[styles.rootFocused]: focus,
|
||||
select_focused: focus,
|
||||
[`select_${size}`]: size,
|
||||
})}
|
||||
>
|
||||
{selectedItem.label}
|
||||
<ArrowIcon size={iconSize[size]} className={clsx('select-arrow', styles.arrow)} />
|
||||
<select
|
||||
{...props}
|
||||
onFocus={() => setFocus(true)}
|
||||
onBlur={() => setFocus(false)}
|
||||
className={clsx('select-element', styles.select)}
|
||||
// wrong typings in preact lib
|
||||
// @ts-ignore
|
||||
selected={selectedItem.value}
|
||||
>
|
||||
{items.map((i) => (
|
||||
<option key={i.value} value={i.value} selected={selected.value === i.value}>
|
||||
<option key={i.value} value={i.value}>
|
||||
{i.label}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, waitFor } from '@testing-library/preact';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/preact';
|
||||
|
||||
import { render } from 'tests/utils';
|
||||
import * as commentsActions from 'store/comments/actions';
|
||||
@@ -8,7 +8,6 @@ import type { StoreState } from 'store';
|
||||
import { SortPicker } from './sort-picker';
|
||||
|
||||
const defaultState = { comments: {} as StoreState['comments'], hiddenUsers: {} };
|
||||
const stateWithSort = { comments: { sort: '-active' } as StoreState['comments'] };
|
||||
|
||||
describe('<SortPicker />', () => {
|
||||
it('should render sort picker with options', () => {
|
||||
@@ -26,10 +25,9 @@ describe('<SortPicker />', () => {
|
||||
});
|
||||
|
||||
it('should render selected element', () => {
|
||||
const { container, queryAllByText } = render(<SortPicker />, stateWithSort);
|
||||
|
||||
expect(queryAllByText('Recently updated')).toHaveLength(2);
|
||||
expect(container.querySelector<HTMLOptionElement>('[value="-active"]')?.selected).toBeTruthy();
|
||||
render(<SortPicker />, { comments: { sort: '-active' } as StoreState['comments'] });
|
||||
expect(screen.getAllByText('Recently updated')).toHaveLength(2);
|
||||
expect(screen.getAllByRole('option')[0].parentElement).toHaveAttribute('selected', '-active');
|
||||
});
|
||||
|
||||
it('should change selected store', async () => {
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
"comment.deleted-comment": "هذا التعليق محذوف",
|
||||
"comment.deleted-user": "حُذِف",
|
||||
"comment.edit": "عدّل",
|
||||
"comment.edit-countdown": "Edit will be disabled",
|
||||
"comment.expired-time": "انتهى وقت التعديل",
|
||||
"comment.go-to-parent": "اذهب للتعليق الأصلي",
|
||||
"comment.hide": "أخفِ",
|
||||
"comment.hide-user-comment": "هل تود إخفاء تعليقات {userName}؟",
|
||||
"comment.paid-patreon": "Patreon Paid Subscriber",
|
||||
"comment.pin": "ثبّت",
|
||||
"comment.pin-comment": "هل تودُّ تثبيت هذا التعليق؟",
|
||||
"comment.reply": "ردّ",
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
"comment.deleted-comment": "Гэты каментар быў выдалены",
|
||||
"comment.deleted-user": "Выдалены",
|
||||
"comment.edit": "Рэдагаваць",
|
||||
"comment.edit-countdown": "Edit will be disabled",
|
||||
"comment.expired-time": "Час рэдагавання сышоў.",
|
||||
"comment.go-to-parent": "Да бацькоўскага каментара",
|
||||
"comment.hide": "Схаваць",
|
||||
"comment.hide-user-comment": "Схаваць каментары карыстальніка {userName}?",
|
||||
"comment.paid-patreon": "Patreon Paid Subscriber",
|
||||
"comment.pin": "Замацаваць",
|
||||
"comment.pin-comment": "Замацаваць гэты каментар?",
|
||||
"comment.reply": "Адказаць",
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
"comment.deleted-comment": "Този коментар е изтрит",
|
||||
"comment.deleted-user": "Изтрит",
|
||||
"comment.edit": "Редактиране",
|
||||
"comment.edit-countdown": "Edit will be disabled",
|
||||
"comment.expired-time": "Времето за редактиране изтече.",
|
||||
"comment.go-to-parent": "Към родителския коментар",
|
||||
"comment.hide": "Скрий",
|
||||
"comment.hide-user-comment": "Искате ли да скриете коментарите на {userName}?",
|
||||
"comment.paid-patreon": "Patreon Paid Subscriber",
|
||||
"comment.pin": "Фиксирай",
|
||||
"comment.pin-comment": "Искате ли да фиксирате този коментар?",
|
||||
"comment.reply": "Отговори",
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
"comment.deleted-comment": "Este comentário foi excluído",
|
||||
"comment.deleted-user": "Excluído",
|
||||
"comment.edit": "Editar",
|
||||
"comment.edit-countdown": "Edit will be disabled",
|
||||
"comment.expired-time": "O tempo de edição expirou.",
|
||||
"comment.go-to-parent": "Ir para comentário principal",
|
||||
"comment.hide": "Ocultar",
|
||||
"comment.hide-user-comment": "Deseja ocultar os comentários de {userName}?",
|
||||
"comment.paid-patreon": "Patreon Paid Subscriber",
|
||||
"comment.pin": "Afixar",
|
||||
"comment.pin-comment": "Deseja afixar este comentário?",
|
||||
"comment.reply": "Responder",
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
"comment.deleted-comment": "Dieser Kommentar wurde gelöscht",
|
||||
"comment.deleted-user": "Gelöscht",
|
||||
"comment.edit": "Bearbeiten",
|
||||
"comment.edit-countdown": "Edit will be disabled",
|
||||
"comment.expired-time": "Die Zeit für eine Bearbeitung ist abgelaufen.",
|
||||
"comment.go-to-parent": "Gehe zum übergeordneten Kommentar",
|
||||
"comment.hide": "Ausblenden",
|
||||
"comment.hide-user-comment": "Möchten Sie die Kommentare von {userName} wirklich ausblenden?",
|
||||
"comment.paid-patreon": "Patreon Paid Subscriber",
|
||||
"comment.pin": "Anheften",
|
||||
"comment.pin-comment": "Möchten Sie diesen Kommentar wirklich oben anheften?",
|
||||
"comment.reply": "Antworten",
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
"comment.deleted-comment": "This comment was deleted",
|
||||
"comment.deleted-user": "Deleted",
|
||||
"comment.edit": "Edit",
|
||||
"comment.edit-countdown": "Edit will be disabled",
|
||||
"comment.expired-time": "Editing time has expired.",
|
||||
"comment.go-to-parent": "Go to parent comment",
|
||||
"comment.hide": "Hide",
|
||||
"comment.hide-user-comment": "Do you want to hide comments of {userName}?",
|
||||
"comment.paid-patreon": "Patreon Paid Subscriber",
|
||||
"comment.pin": "Pin",
|
||||
"comment.pin-comment": "Do you want to pin this comment?",
|
||||
"comment.reply": "Reply",
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
"comment.deleted-comment": "Este comentario fue eliminado",
|
||||
"comment.deleted-user": "Eliminado",
|
||||
"comment.edit": "Editar",
|
||||
"comment.edit-countdown": "Edit will be disabled",
|
||||
"comment.expired-time": "El tiempo de edición ha expirado.",
|
||||
"comment.go-to-parent": "Ir al comentario padre",
|
||||
"comment.hide": "Ocultar",
|
||||
"comment.hide-user-comment": "¿Quieres ocultar los comentarios de {userName}?",
|
||||
"comment.paid-patreon": "Patreon Paid Subscriber",
|
||||
"comment.pin": "Anclar",
|
||||
"comment.pin-comment": "¿Quieres anclar este comentario?",
|
||||
"comment.reply": "Responder",
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
"comment.deleted-comment": "Tämä kommentti poistettiin",
|
||||
"comment.deleted-user": "Poistettu",
|
||||
"comment.edit": "Muokkaa",
|
||||
"comment.edit-countdown": "Edit will be disabled",
|
||||
"comment.expired-time": "Muokkausaika on päättynyt.",
|
||||
"comment.go-to-parent": "Siirry juurikommenttiin",
|
||||
"comment.hide": "Piilota",
|
||||
"comment.hide-user-comment": "Haluatko piilottaa käyttäjän {userName} kommentit?",
|
||||
"comment.paid-patreon": "Patreon Paid Subscriber",
|
||||
"comment.pin": "Sitoo",
|
||||
"comment.pin-comment": "Haluatko kiinnittää tämän kommentin?",
|
||||
"comment.reply": "Vastaa",
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
"comment.deleted-comment": "Ce commentaire a été supprimé",
|
||||
"comment.deleted-user": "Supprimé",
|
||||
"comment.edit": "Modifier",
|
||||
"comment.edit-countdown": "Edit will be disabled",
|
||||
"comment.expired-time": "La période de modification est expirée.",
|
||||
"comment.go-to-parent": "Aller au commentaire parent",
|
||||
"comment.hide": "Masquer",
|
||||
"comment.hide-user-comment": "Voulez-vous masquer les commentaires de {userName} ?",
|
||||
"comment.paid-patreon": "Patreon Paid Subscriber",
|
||||
"comment.pin": "Epingler",
|
||||
"comment.pin-comment": "Voulez-vous épingler ce commentaire ?",
|
||||
"comment.reply": "Répondre",
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
"comment.deleted-comment": "Questo commento è stato eliminato",
|
||||
"comment.deleted-user": "Eliminato",
|
||||
"comment.edit": "Modifica",
|
||||
"comment.edit-countdown": "Edit will be disabled",
|
||||
"comment.expired-time": "Il tempo per le modifiche è scaduto.",
|
||||
"comment.go-to-parent": "Vai al commento padre",
|
||||
"comment.hide": "Nascondi",
|
||||
"comment.hide-user-comment": "Vuoi nascondere i commenti di {userName}?",
|
||||
"comment.paid-patreon": "Patreon Paid Subscriber",
|
||||
"comment.pin": "Fissa in alto",
|
||||
"comment.pin-comment": "Vuoi fissare in alto questo commento?",
|
||||
"comment.reply": "Rispondi",
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
"comment.deleted-comment": "このコメントは削除されました",
|
||||
"comment.deleted-user": "削除済み",
|
||||
"comment.edit": "編集",
|
||||
"comment.edit-countdown": "Edit will be disabled",
|
||||
"comment.expired-time": "編集時間の有効期限が切れています。",
|
||||
"comment.go-to-parent": "親コメントに移動",
|
||||
"comment.hide": "非表示",
|
||||
"comment.hide-user-comment": "{userName}のコメントを非表示にしますか?",
|
||||
"comment.paid-patreon": "Patreon Paid Subscriber",
|
||||
"comment.pin": "ピン留め",
|
||||
"comment.pin-comment": "このコメントをピン留めしますか?",
|
||||
"comment.reply": "返信",
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
"comment.deleted-comment": "이 댓글이 삭제되었습니다",
|
||||
"comment.deleted-user": "삭제됨",
|
||||
"comment.edit": "편집",
|
||||
"comment.edit-countdown": "Edit will be disabled",
|
||||
"comment.expired-time": "편집 시간이 만료되었습니다.",
|
||||
"comment.go-to-parent": "상위 댓글로 이동",
|
||||
"comment.hide": "숨기기",
|
||||
"comment.hide-user-comment": "{userName} 님의 댓글을 숨기시겠어요?",
|
||||
"comment.paid-patreon": "Patreon Paid Subscriber",
|
||||
"comment.pin": "고정",
|
||||
"comment.pin-comment": "이 댓글을 고정하시겠어요?",
|
||||
"comment.reply": "답변하기",
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
"comment.deleted-comment": "Ten komentarz został usunięty",
|
||||
"comment.deleted-user": "Usunięto",
|
||||
"comment.edit": "Edytuj",
|
||||
"comment.edit-countdown": "Edit will be disabled",
|
||||
"comment.expired-time": "Czas przeznaczony na edycję skończył się.",
|
||||
"comment.go-to-parent": "Idź do komentarza nadrzędnego",
|
||||
"comment.hide": "Ukryj",
|
||||
"comment.hide-user-comment": "Czy chcesz ukryć komentarze użytkownika {userName}?",
|
||||
"comment.paid-patreon": "Patreon Paid Subscriber",
|
||||
"comment.pin": "Przypnij",
|
||||
"comment.pin-comment": "Czy chcesz przypiąć ten komentarz?",
|
||||
"comment.reply": "Odpowiedz",
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
"comment.deleted-comment": "Комментарий удален",
|
||||
"comment.deleted-user": "Удален",
|
||||
"comment.edit": "Редактировать",
|
||||
"comment.edit-countdown": "Edit will be disabled",
|
||||
"comment.expired-time": "Время редактирования истекло.",
|
||||
"comment.go-to-parent": "Перейти к началу ветки",
|
||||
"comment.hide": "Скрыть",
|
||||
"comment.hide-user-comment": "Скрыть комментарии от пользователя {userName}?",
|
||||
"comment.paid-patreon": "Patreon Paid Subscriber",
|
||||
"comment.pin": "Закрепить",
|
||||
"comment.pin-comment": "Закрепить комментарий?",
|
||||
"comment.reply": "Ответить",
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
"comment.deleted-comment": "Yorum silindi",
|
||||
"comment.deleted-user": "Silindi",
|
||||
"comment.edit": "Düzenle",
|
||||
"comment.edit-countdown": "Edit will be disabled",
|
||||
"comment.expired-time": "Düzenleme zamanı aşıldı.",
|
||||
"comment.go-to-parent": "Üst yoruma git",
|
||||
"comment.hide": "Gizle",
|
||||
"comment.hide-user-comment": "{userName} kullanıcısının yorumları gizlensin mi?",
|
||||
"comment.paid-patreon": "Patreon Paid Subscriber",
|
||||
"comment.pin": "Sabitle",
|
||||
"comment.pin-comment": "Bu yorum sabitlensin mi?",
|
||||
"comment.reply": "Yanıtla",
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
"comment.deleted-comment": "Коментар було видалено",
|
||||
"comment.deleted-user": "Видалено",
|
||||
"comment.edit": "Редагувати",
|
||||
"comment.edit-countdown": "Edit will be disabled",
|
||||
"comment.expired-time": "Час редагування минув.",
|
||||
"comment.go-to-parent": "До батьківського коментаря",
|
||||
"comment.hide": "Приховати",
|
||||
"comment.hide-user-comment": "Приховати коментар від користувача {userName}?",
|
||||
"comment.paid-patreon": "Patreon Paid Subscriber",
|
||||
"comment.pin": "Закріпити",
|
||||
"comment.pin-comment": "Закріпити коментар?",
|
||||
"comment.reply": "Відповісти",
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
"comment.deleted-comment": "Bình luận này đã bị xoá",
|
||||
"comment.deleted-user": "Đã xoá",
|
||||
"comment.edit": "Sửa",
|
||||
"comment.edit-countdown": "Edit will be disabled",
|
||||
"comment.expired-time": "Thời gian chỉnh sửa đã hết.",
|
||||
"comment.go-to-parent": "Đi tới bình luận chính",
|
||||
"comment.hide": "Ẩn",
|
||||
"comment.hide-user-comment": "Bạn có muốn ẩn bình luận của {userName}?",
|
||||
"comment.paid-patreon": "Patreon Paid Subscriber",
|
||||
"comment.pin": "Ghim",
|
||||
"comment.pin-comment": "Bạn có muốn ghim bình luận này?",
|
||||
"comment.reply": "Trả lời",
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
"comment.deleted-comment": "此评论已删除",
|
||||
"comment.deleted-user": "已删除评论",
|
||||
"comment.edit": "编辑",
|
||||
"comment.edit-countdown": "Edit will be disabled",
|
||||
"comment.expired-time": "编辑时间已过期",
|
||||
"comment.go-to-parent": "转到父评论",
|
||||
"comment.hide": "隐藏",
|
||||
"comment.hide-user-comment": "您要隐藏 {userName} 的评论吗?",
|
||||
"comment.paid-patreon": "Patreon Paid Subscriber",
|
||||
"comment.pin": "固定",
|
||||
"comment.pin-comment": "您要固定此评论吗?",
|
||||
"comment.reply": "回复",
|
||||
|
||||
@@ -27,7 +27,7 @@ button {
|
||||
transition-timing-function: linear;
|
||||
transition-duration: 150ms;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#remark42 {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
/** return shallow clone of object without given props */
|
||||
export function exclude<T extends object, K extends keyof T>(o: T, ...exclude: K[]): Pick<T, Exclude<keyof T, K>> {
|
||||
const clone = { ...o };
|
||||
for (const item of exclude) {
|
||||
delete clone[item];
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
Reference in New Issue
Block a user