Files
remark42/frontend/app/components/comment/comment.tsx
T

885 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { h, JSX, Component, createRef, ComponentType } from 'preact';
import b from 'bem-react-helper';
import { getHandleClickProps } from 'common/accessibility';
import { COMMENT_NODE_CLASSNAME_PREFIX } from 'common/constants';
import { StaticStore } from 'common/static-store';
import { debounce } from 'utils/debounce';
import { copy } from 'common/copy';
import { Theme, BlockTTL, Comment as CommentType, PostInfo, User, CommentMode, Profile } from 'common/types';
import { extractErrorMessageFromResponse, FetcherError } from 'utils/errorUtils';
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 { getPreview, uploadImage } from 'common/api';
import { postMessageToParent } from 'utils/post-message';
import { FormattedMessage, IntlShape, defineMessages } from 'react-intl';
import { getVoteMessage, VoteMessagesTypes } from './getVoteMessage';
import { getBlockingDurations } from './getBlockingDurations';
import { boundActions } from './connected-comment';
import './styles';
export type CommentProps = {
user: User | null;
CommentForm?: ComponentType<CommentFormProps>;
data: CommentType;
repliesCount?: number;
post_info?: PostInfo;
/** whether comment's user is banned */
isUserBanned?: boolean;
isCommentsDisabled: boolean;
/** edit mode: is comment should have reply, or edit Input */
editMode?: CommentMode;
/**
* "main" view used in main case,
* "pinned" view used in pinned block,
* "user" is for user comments widget,
* "preview" is for last comments page
*/
view: 'main' | 'pinned' | 'user' | 'preview';
/** defines whether comment should have reply/edit actions */
disabled?: boolean;
collapsed?: boolean;
theme: Theme;
inView?: boolean;
level?: number;
mix?: string;
getPreview?: typeof getPreview;
uploadImage?: typeof uploadImage;
intl: IntlShape;
} & Partial<typeof boundActions>;
export interface State {
renderDummy: boolean;
isCopied: boolean;
editDeadline: Date | null;
voteErrorMessage: string | null;
/**
* delta of the score:
* default is 0.
* if user upvoted delta will be incremented
* if downvoted delta will be decremented
*/
scoreDelta: number;
/**
* score copied from props, that updates instantly,
* without server response
*/
cachedScore: number;
initial: boolean;
}
export class Comment extends Component<CommentProps, State> {
votingPromise: Promise<unknown> = Promise.resolve();
/** comment text node. Used in comment text copying */
textNode = createRef<HTMLDivElement>();
updateState = (props: CommentProps) => {
const newState: Partial<State> = {
scoreDelta: props.data.vote,
cachedScore: props.data.score,
};
if (props.inView) {
newState.renderDummy = false;
}
// set comment edit timer
if (props.user && props.user.id === props.data.user.id) {
const editDuration = StaticStore.config.edit_duration;
const timeDiff = StaticStore.serverClientTimeDiff || 0;
const editDeadline = new Date(new Date(props.data.time).getTime() + timeDiff + editDuration * 1000);
if (editDeadline < new Date()) {
newState.editDeadline = null;
} else {
newState.editDeadline = editDeadline;
}
}
return newState;
};
state = {
renderDummy: typeof this.props.inView === 'boolean' ? !this.props.inView : false,
isCopied: false,
editDeadline: null,
voteErrorMessage: null,
scoreDelta: 0,
cachedScore: this.props.data.score,
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));
}
componentDidMount() {
// eslint-disable-next-line react/no-did-mount-set-state
this.setState({ initial: false });
}
toggleReplying = () => {
const { editMode, setReplyEditState, data } = this.props;
setReplyEditState?.({ id: data.id, state: editMode === CommentMode.Reply ? CommentMode.None : CommentMode.Reply });
};
toggleEditing = () => {
const { editMode, setReplyEditState, data } = this.props;
setReplyEditState?.({ id: data.id, state: editMode === CommentMode.Edit ? CommentMode.None : CommentMode.Edit });
};
toggleUserInfoVisibility = () => {
const profile: Profile = { ...this.props.data.user };
if (this.props.user?.id === profile.id) {
profile.current = '1';
}
postMessageToParent({ profile });
};
togglePin = () => {
const value = !this.props.data.pin;
const intl = this.props.intl;
const promptMessage = value ? intl.formatMessage(messages.pinComment) : intl.formatMessage(messages.unpinComment);
if (window.confirm(promptMessage)) {
this.props.setPinState!(this.props.data.id, value);
}
};
toggleVerify = () => {
const value = !this.props.data.user.verified;
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 });
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();
}
// 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 => {
const { user } = this.props.data;
const blockingDurations = getBlockingDurations(this.props.intl);
const blockDuration = blockingDurations.find((el) => el.value === ttl);
// blocking duration may be undefined if user hasn't selected anything
// and ttl equals "Blocking period"
if (!blockDuration) return;
const duration = blockDuration.label;
const blockUser = this.props.intl.formatMessage(messages.blockUser, {
userName: user.name,
duration: duration.toLowerCase(),
});
if (window.confirm(blockUser)) {
this.props.blockUser!(user.id, user.name, ttl);
}
}, 100);
onUnblockUserClick = () => {
const { user } = this.props.data;
const unblockUser = this.props.intl.formatMessage(messages.unblockUser);
if (window.confirm(unblockUser)) {
this.props.unblockUser!(user.id);
}
};
deleteComment = () => {
const deleteComment = this.props.intl.formatMessage(messages.deleteMessage);
if (window.confirm(deleteComment)) {
this.props.setReplyEditState!({ id: this.props.data.id, state: CommentMode.None });
this.props.removeComment!(this.props.data.id);
}
};
hideUser = () => {
const hideUserComment = this.props.intl.formatMessage(messages.hideUserComments, {
userName: this.props.data.user.name,
});
if (!window.confirm(hideUserComment)) return;
this.props.hideUser!(this.props.data.user);
};
handleVoteError = (e: FetcherError, originalScore: number, originalDelta: number) => {
this.setState({
scoreDelta: originalDelta,
cachedScore: originalScore,
voteErrorMessage: extractErrorMessageFromResponse(e, this.props.intl),
});
};
sendVotingRequest = (votingValue: number, originalScore: number, originalDelta: number) => {
this.votingPromise = this.votingPromise
.then(() => this.props.putCommentVote!(this.props.data.id, votingValue))
.catch((e) => this.handleVoteError(e, originalScore, originalDelta));
};
increaseScore = () => {
const { cachedScore, scoreDelta } = this.state;
if (scoreDelta === 1) return;
this.setState({
scoreDelta: scoreDelta + 1,
cachedScore: cachedScore + 1,
voteErrorMessage: null,
});
this.sendVotingRequest(1, cachedScore, scoreDelta);
};
decreaseScore = () => {
const { cachedScore, scoreDelta } = this.state;
if (scoreDelta === -1) return;
this.setState({
scoreDelta: scoreDelta - 1,
cachedScore: cachedScore - 1,
voteErrorMessage: null,
});
this.sendVotingRequest(-1, cachedScore, scoreDelta);
};
addComment = async (text: string, title: string, pid?: CommentType['id']) => {
await this.props.addComment!(text, title, pid);
this.props.setReplyEditState!({ id: this.props.data.id, state: CommentMode.None });
};
updateComment = async (id: CommentType['id'], text: string) => {
await this.props.updateComment!(id, text);
this.props.setReplyEditState!({ id: this.props.data.id, state: CommentMode.None });
};
scrollToParent = (evt: Event) => {
const { pid } = this.props.data;
const parentCommentNode = document.getElementById(`${COMMENT_NODE_CLASSNAME_PREFIX}${pid}`);
evt.preventDefault();
if (!parentCommentNode) {
return;
}
const top = parentCommentNode.getBoundingClientRect().top;
if (postMessageToParent({ scrollTo: top })) {
return;
}
parentCommentNode.scrollIntoView();
};
copyComment = () => {
const username = this.props.data.user.name;
const time = getLocalDatetime(this.props.intl, new Date(this.props.data.time));
const text = this.textNode.current?.textContent || '';
copy(`<b>${username}</b>&nbsp;${time}<br>${text.replace(/\n+/g, '<br>')}`);
this.setState({ isCopied: true }, () => {
setTimeout(() => this.setState({ isCopied: false }), 3000);
});
};
/**
* 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;
};
/**
* returns reason for disabled downvoting
*/
getDownvoteDisabledReason = (): string | null => {
const intl = this.props.intl;
if (!(this.props.view === 'main' || this.props.view === 'pinned'))
return getVoteMessage(VoteMessagesTypes.ONLY_POST_PAGE, intl);
if (this.props.post_info?.read_only) return getVoteMessage(VoteMessagesTypes.READONLY, intl);
if (this.props.data.delete) return getVoteMessage(VoteMessagesTypes.DELETED, intl);
if (this.isCurrentUser()) return getVoteMessage(VoteMessagesTypes.OWN_COMMENT, intl);
if (StaticStore.config.positive_score && this.props.data.score < 1)
return getVoteMessage(VoteMessagesTypes.ONLY_POSITIVE, intl);
if (this.isGuest()) return getVoteMessage(VoteMessagesTypes.GUEST, intl);
if (this.isAnonymous() && !StaticStore.config.anon_vote) return getVoteMessage(VoteMessagesTypes.ANONYMOUS, intl);
return null;
};
/**
* returns reason for disabled upvoting
*/
getUpvoteDisabledReason = (): string | null => {
const intl = this.props.intl;
if (!(this.props.view === 'main' || this.props.view === 'pinned'))
return getVoteMessage(VoteMessagesTypes.ONLY_POST_PAGE, intl);
if (this.props.post_info?.read_only) return getVoteMessage(VoteMessagesTypes.READONLY, intl);
if (this.props.data.delete) return getVoteMessage(VoteMessagesTypes.DELETED, intl);
if (this.isCurrentUser()) return getVoteMessage(VoteMessagesTypes.OWN_COMMENT, intl);
if (this.isGuest()) return getVoteMessage(VoteMessagesTypes.GUEST, intl);
if (this.isAnonymous() && !StaticStore.config.anon_vote) return getVoteMessage(VoteMessagesTypes.ANONYMOUS, intl);
return null;
};
getCommentControls = (): JSX.Element[] => {
const isAdmin = this.isAdmin();
const isCurrentUser = this.isCurrentUser();
const controls: JSX.Element[] = [];
if (this.props.data.delete) {
return controls;
}
if (!(this.props.view === 'main' || this.props.view === 'pinned')) {
return controls;
}
if (isAdmin) {
controls.push(
this.state.isCopied ? (
<span className="comment__control comment__control_view_inactive">
<FormattedMessage id="comment.copied" defaultMessage="Copied!" />
</span>
) : (
<Button kind="link" onClick={this.copyComment} mix="comment__control">
<FormattedMessage id="comment.copy" defaultMessage="Copy" />
</Button>
)
);
controls.push(
<Button kind="link" onClick={this.togglePin} mix="comment__control">
{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">
<FormattedMessage id="comment.hide" defaultMessage="Hide" />
</Button>
);
}
if (isAdmin) {
if (this.props.isUserBanned) {
controls.push(
<Button kind="link" onClick={this.onUnblockUserClick} mix="comment__control">
<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">
<FormattedMessage id="comment.delete" defaultMessage="Delete" />
</Button>
);
}
}
return controls;
};
render(props: CommentProps, state: State) {
const isAdmin = this.isAdmin();
const isGuest = this.isGuest();
const isCurrentUser = this.isCurrentUser();
const isReplying = props.editMode === CommentMode.Reply;
const isEditing = props.editMode === CommentMode.Edit;
const lowCommentScore = StaticStore.config.low_score;
const downvotingDisabledReason = this.getDownvoteDisabledReason();
const isDownvotingDisabled = downvotingDisabledReason !== null;
const upvotingDisabledReason = this.getUpvoteDisabledReason();
const isUpvotingDisabled = upvotingDisabledReason !== null;
const editable = props.repliesCount === 0 && state.editDeadline;
const scoreSignEnabled = !StaticStore.config.positive_score;
const uploadImageHandler = this.isAnonymous() ? undefined : this.props.uploadImage;
const commentControls = this.getCommentControls();
const intl = props.intl;
const CommentForm = this.props.CommentForm || null;
/**
* CommentType adapted for rendering
*/
const o = {
...props.data,
controversyText: intl.formatMessage(messages.controversy, {
value: (props.data.controversy || 0).toFixed(2),
}),
text:
props.view === 'preview'
? getTextSnippet(props.data.text)
: props.data.delete
? intl.formatMessage(messages.deletedComment)
: props.data.text,
time: new Date(props.data.time),
orig: isEditing
? props.data.orig &&
props.data.orig.replace(/&[#A-Za-z0-9]+;/gi, (entity) => {
const span = document.createElement('span');
span.innerHTML = entity;
return span.innerText;
})
: props.data.orig,
score: {
value: Math.abs(state.cachedScore),
sign: !scoreSignEnabled ? '' : state.cachedScore > 0 ? '+' : state.cachedScore < 0 ? '' : null,
view: state.cachedScore > 0 ? 'positive' : state.cachedScore < 0 ? 'negative' : undefined,
},
user: props.data.user,
};
const defaultMods = {
disabled: props.disabled,
pinned: props.data.pin,
// TODO: we also have critical_score, so we need to collapse comments with it in future
useless:
!!props.isUserBanned ||
!!props.data.delete ||
(props.view !== 'preview' && props.data.score < lowCommentScore && !props.data.pin && !props.disabled),
// TODO: add default view mod or don't?
guest: isGuest,
view: props.view === 'main' || props.view === 'pinned' ? props.data.user.admin && 'admin' : props.view,
replying: props.view === 'main' && isReplying,
editing: props.view === 'main' && isEditing,
theme: props.view === 'preview' ? undefined : props.theme,
level: props.level,
collapsed: props.collapsed,
};
if (props.view === 'preview') {
return (
<article className={b('comment', { mix: props.mix }, defaultMods)}>
<div className="comment__body">
{!!o.title && (
<div className="comment__title">
<a className="comment__title-link" href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`}>
{o.title}
</a>
</div>
)}
<div className="comment__info">
{!!o.title && o.user.name}
{!o.title && (
<a href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`} className="comment__username">
{o.user.name}
</a>
)}
</div>{' '}
<div
className={b('comment__text', { mix: b('raw-content', {}, { theme: props.theme }) })}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: o.text }}
/>
</div>
</article>
);
}
if (this.state.renderDummy && !props.editMode) {
const [width, height] = this.base
? [(this.base as Element).scrollWidth, (this.base as Element).scrollHeight]
: [100, 100];
return (
<article
id={props.disabled ? undefined : `${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`}
style={{
width: `${width}px`,
height: `${height}px`,
}}
/>
);
}
const goToParentMessage = intl.formatMessage(messages.goToParent);
return (
<article
className={b('comment', { mix: this.props.mix }, defaultMods)}
id={props.disabled ? undefined : `${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`}
>
{props.view === 'user' && o.title && (
<div className="comment__title">
<a className="comment__title-link" href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`}>
{o.title}
</a>
</div>
)}
<div className="comment__info">
{props.view !== 'user' && !props.collapsed && (
<div className="comment__avatar">
<Avatar url={o.user.picture} />
</div>
)}
{props.view !== 'user' && (
<button onClick={() => this.toggleUserInfoVisibility()} className="comment__username">
{o.user.name}
</button>
)}
{isAdmin && props.view !== 'user' && (
<span
{...getHandleClickProps(this.toggleVerify)}
aria-label={intl.formatMessage(messages.toggleVerification)}
title={intl.formatMessage(o.user.verified ? messages.verifiedUser : messages.unverifiedUser)}
className={b('comment__verification', {}, { active: o.user.verified, clickable: true })}
/>
)}
{!isAdmin && !!o.user.verified && props.view !== 'user' && (
<span
title={intl.formatMessage(messages.verifiedUser)}
className={b('comment__verification', {}, { active: true })}
/>
)}
<a href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`} className="comment__time">
{getLocalDatetime(this.props.intl, o.time)}
</a>
{!!props.level && props.level > 0 && props.view === 'main' && (
<a
className="comment__link-to-parent"
href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.pid}`}
aria-label={goToParentMessage}
title={goToParentMessage}
onClick={(e) => this.scrollToParent(e)}
>
{' '}
</a>
)}
{props.isUserBanned && props.view !== 'user' && (
<span className="comment__status">
<FormattedMessage id="comment.blocked-user" defaultMessage="Blocked" />
</span>
)}
{isAdmin && !props.isUserBanned && props.data.delete && (
<span className="comment__status">
<FormattedMessage id="comment.deleted-user" defaultMessage="Deleted" />
</span>
)}
<span className={b('comment__score', {}, { view: o.score.view })}>
<span
className={b(
'comment__vote',
{},
{ type: 'up', selected: state.scoreDelta === 1, disabled: isUpvotingDisabled }
)}
aria-disabled={state.scoreDelta === 1 || isUpvotingDisabled ? 'true' : 'false'}
{...getHandleClickProps(isUpvotingDisabled ? undefined : this.increaseScore)}
title={upvotingDisabledReason || undefined}
>
Vote up
</span>
<span className="comment__score-value" title={o.controversyText}>
{o.score.sign}
{o.score.value}
</span>
<span
className={b(
'comment__vote',
{},
{ type: 'down', selected: state.scoreDelta === -1, disabled: isDownvotingDisabled }
)}
aria-disabled={state.scoreDelta === -1 || isUpvotingDisabled ? 'true' : 'false'}
{...getHandleClickProps(isDownvotingDisabled ? undefined : this.decreaseScore)}
title={downvotingDisabledReason || undefined}
>
Vote down
</span>
</span>
</div>
<div className="comment__body">
{!!state.voteErrorMessage && (
<div className="voting__error" role="alert">
<FormattedMessage
id="comment.vote-error"
defaultMessage="Voting error: {voteErrorMessage}"
values={{ voteErrorMessage: state.voteErrorMessage }}
/>
</div>
)}
{(!props.collapsed || props.view === 'pinned') && (
<div
className={b('comment__text', { mix: b('raw-content', {}, { theme: props.theme }) })}
ref={this.textNode}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: o.text }}
/>
)}
{(!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>
)}
</div>
{CommentForm && isReplying && props.view === 'main' && (
<CommentForm
id={o.id}
intl={this.props.intl}
user={props.user}
theme={props.theme}
mode="reply"
mix="comment__input"
onSubmit={(text: string, title: string) => this.addComment(text, title, o.id)}
onCancel={this.toggleReplying}
getPreview={this.props.getPreview!}
autofocus={true}
uploadImage={uploadImageHandler}
simpleView={StaticStore.config.simple_view}
/>
)}
{CommentForm && isEditing && props.view === 'main' && (
<CommentForm
id={o.id}
intl={this.props.intl}
user={props.user}
theme={props.theme}
value={o.orig}
mode="edit"
mix="comment__input"
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}
autofocus={true}
uploadImage={uploadImageHandler}
simpleView={StaticStore.config.simple_view}
/>
)}
</article>
);
}
}
function getTextSnippet(html: string) {
const LENGTH = 100;
const tmp = document.createElement('div');
tmp.innerHTML = html.replace('</p><p>', ' ');
const result = tmp.innerText || '';
const snippet = result.substr(0, LENGTH);
return snippet.length === LENGTH && result.length !== LENGTH ? `${snippet}...` : snippet;
}
function getLocalDatetime(intl: IntlShape, date: Date) {
return intl.formatMessage(messages.commentTime, {
day: intl.formatDate(date),
time: intl.formatTime(date),
});
}
const messages = defineMessages({
deleteMessage: {
id: 'comment.delete-message',
defaultMessage: 'Do you want to delete this comment?',
},
hideUserComments: {
id: 'comment.hide-user-comment',
defaultMessage: 'Do you want to hide comments of {userName}?',
},
pinComment: {
id: 'comment.pin-comment',
defaultMessage: 'Do you want to pin this comment?',
},
unpinComment: {
id: 'comment.unpin-comment',
defaultMessage: 'Do you want to unpin this comment?',
},
verifyUser: {
id: 'comment.verify-user',
defaultMessage: 'Do you want to verify {userName}?',
},
unverifyUser: {
id: 'comment.unverify-user',
defaultMessage: 'Do you want to unverify {userName}?',
},
blockUser: {
id: 'comment.block-user',
defaultMessage: 'Do you want to block {userName} {duration}?',
},
unblockUser: {
id: 'comment.unblock-user',
defaultMessage: 'Do you want to unblock this user?',
},
deletedComment: {
id: 'comment.deleted-comment',
defaultMessage: 'This comment was deleted',
},
controversy: {
id: 'comment.controversy',
defaultMessage: 'Controversy: {value}',
},
toggleVerification: {
id: 'comment.toggle-verification',
defaultMessage: 'Toggle verification',
},
verifiedUser: {
id: 'comment.verified-user',
defaultMessage: 'Verified user',
},
unverifiedUser: {
id: 'comment.unverified-user',
defaultMessage: 'Unverified user',
},
goToParent: {
id: 'comment.go-to-parent',
defaultMessage: 'Go to parent comment',
},
expiredTime: {
id: 'comment.expired-time',
defaultMessage: 'Editing time has expired.',
},
commentTime: {
id: 'comment.time',
defaultMessage: '{day} at {time}',
},
});