update comment actions block

This commit is contained in:
Paul Mineev
2022-04-15 10:32:01 -05:00
committed by Umputun
parent cd7a2a3c73
commit 58b0841360
46 changed files with 597 additions and 495 deletions
-7
View File
@@ -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;
@@ -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;
}
}
}
@@ -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;
}
}
@@ -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();
});
});
+65 -200
View File
@@ -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',
-12
View File
@@ -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';
+44
View File
@@ -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} />;
}
}
+1 -1
View File
@@ -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 {
+28 -9
View File
@@ -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>
))}
+4 -6
View File
@@ -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 () => {
+2
View File
@@ -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": "ردّ",
+2
View File
@@ -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": "Адказаць",
+2
View File
@@ -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": "Отговори",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "返信",
+2
View File
@@ -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": "답변하기",
+2
View File
@@ -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",
+2
View File
@@ -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": "Ответить",
+2
View File
@@ -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",
+2
View File
@@ -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": "Відповісти",
+2
View File
@@ -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",
+2
View File
@@ -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": "回复",
+1 -1
View File
@@ -27,7 +27,7 @@ button {
transition-timing-function: linear;
transition-duration: 150ms;
appearance: none;
cursor: pointer;
cursor: pointer;
}
#remark42 {
-8
View File
@@ -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;
}