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; 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; 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 { votingPromise: Promise = Promise.resolve(); /** comment text node. Used in comment text copying */ textNode = createRef(); updateState = (props: CommentProps) => { const newState: Partial = { 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(`${username} ${time}
${text.replace(/\n+/g, '
')}`); 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 ? ( ) : ( ) ); controls.push( ); } if (!isCurrentUser) { controls.push( ); } if (isAdmin) { if (this.props.isUserBanned) { controls.push( ); } const blockingDurations = getBlockingDurations(this.props.intl); if (this.props.user!.id !== this.props.data.user.id && !this.props.isUserBanned) { controls.push( ); } if (!this.props.data.delete) { controls.push( ); } } 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 ( ); } if (this.state.renderDummy && !props.editMode) { const [width, height] = this.base ? [(this.base as Element).scrollWidth, (this.base as Element).scrollHeight] : [100, 100]; return (
); } const goToParentMessage = intl.formatMessage(messages.goToParent); return (
{props.view === 'user' && o.title && ( )}
{props.view !== 'user' && !props.collapsed && (
)} {props.view !== 'user' && ( )} {isAdmin && props.view !== 'user' && ( )} {!isAdmin && !!o.user.verified && props.view !== 'user' && ( )} {getLocalDatetime(this.props.intl, o.time)} {!!props.level && props.level > 0 && props.view === 'main' && ( this.scrollToParent(e)} > {' '} )} {props.isUserBanned && props.view !== 'user' && ( )} {isAdmin && !props.isUserBanned && props.data.delete && ( )} Vote up {o.score.sign} {o.score.value} Vote down
{!!state.voteErrorMessage && (
)} {(!props.collapsed || props.view === 'pinned') && (
)} {(!props.collapsed || props.view === 'pinned') && (
{!props.data.delete && !props.isCommentsDisabled && !props.disabled && props.view === 'main' && ( )} {!props.data.delete && !props.disabled && !!o.orig && isCurrentUser && (editable || isEditing) && props.view === 'main' && [ , !isAdmin && ( ), state.editDeadline && ( this.setState({ editDeadline: null, }) } /> ), ]} {commentControls.length > 0 && {commentControls}}
)}
{CommentForm && isReplying && props.view === 'main' && ( 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' && ( 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} /> )}
); } } function getTextSnippet(html: string) { const LENGTH = 100; const tmp = document.createElement('div'); tmp.innerHTML = html.replace('

', ' '); 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}', }, });