import { h, JSX, Component, createRef, ComponentType } from 'preact'; import { FormattedMessage, IntlShape, defineMessages } from 'react-intl'; import b from 'bem-react-helper'; import clsx from 'clsx'; 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 { isUserAnonymous } from 'utils/isUserAnonymous'; import { CommentFormProps } from 'components/comment-form'; import { Avatar } from 'components/avatar'; 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'; 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?: number; initial: boolean; } export class Comment extends Component { votingPromise: Promise = Promise.resolve(); /** comment text node. Used in comment text copying */ textNode = createRef(); /** * 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 = {}; if (props.inView) { newState.renderDummy = false; } // set comment edit timer if (this.isCurrentUser()) { const editDuration = StaticStore.config.edit_duration; const timeDiff = StaticStore.serverClientTimeDiff || 0; const editDeadline = new Date(props.data.time).getTime() + timeDiff + editDuration * 1000; newState.editDeadline = editDeadline > Date.now() ? editDeadline : undefined; } return newState; }; state = { renderDummy: typeof this.props.inView === 'boolean' ? !this.props.inView : false, isCopied: false, editDeadline: undefined, voteErrorMessage: null, initial: true, ...this.updateState(this.props), }; 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 = intl.formatMessage(value ? messages.verifyUser : messages.unverifyUser, { userName }); if (window.confirm(promptMessage)) { this.props.setVerifiedStatus!(userId, value); } }; 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); } }; 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); unblockUser = () => { 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); }; 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(); }; get isVotesDisabled(): boolean { return ( this.props.view !== 'main' || this.props.post_info?.read_only || this.props.data.delete || this.isCurrentUser() || this.isGuest() || (!StaticStore.config.anon_vote && this.isAnonymous()) ); } copyComment = async () => { const { name } = this.props.data.user; const time = getLocalDatetime(this.props.intl, new Date(this.props.data.time)); const text = this.textNode.current?.textContent ?? ''; try { await copy(`${name} ${time}\n${text}`); } catch (e) { console.log(e); } this.setState({ isCopied: true }, () => { setTimeout(() => this.setState({ isCopied: false }), 3000); }); }; render(props: CommentProps, state: State): JSX.Element { 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 uploadImageHandler = this.isAnonymous() ? undefined : this.props.uploadImage; const intl = props.intl; const CommentForm = this.props.CommentForm || null; /** * CommentType adapted for rendering */ const o = { ...props.data, 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, 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 < StaticStore.config.low_score && !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' && ( )} {o.user.paid_sub && ( {intl.formatMessage(messages.paidPatreon)} )}
{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 && ( )} {this.props.view !== 'pinned' && ( )}
{(!props.collapsed || props.view === 'pinned') && (
)} {(!props.collapsed || !this.props.data.delete) && props.view !== 'pinned' && ( this.setState({ editDeadline: undefined })} currentUser={isCurrentUser} bannedUser={props.isUserBanned} onCopy={this.copyComment} onTogglePin={this.togglePin} onToggleEditing={this.toggleEditing} onDelete={this.deleteComment} onHideUser={this.hideUser} onBlockUser={this.blockUser} onUnblockUser={this.unblockUser} /> )}
{CommentForm && isReplying && props.view === 'main' && ( this.addComment(text, title, o.id)} onCancel={this.toggleReplying} getPreview={this.props.getPreview!} autofocus={true} uploadImage={uploadImageHandler} /> )} {CommentForm && isEditing && props.view === 'main' && ( this.updateComment(props.data.id, text)} onCancel={this.toggleEditing} getPreview={this.props.getPreview!} errorMessage={state.editDeadline === undefined ? intl.formatMessage(messages.expiredTime) : undefined} autofocus={true} uploadImage={uploadImageHandler} /> )}
); } } 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', }, 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}', }, paidPatreon: { id: 'comment.paid-patreon', defaultMessage: 'Patreon Paid Subscriber', }, });