Files
remark42/web/app/components/comment/comment.jsx
T

730 lines
22 KiB
React
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.
/** @jsx h */
import { h, Component } from 'preact';
import api from 'common/api';
import { getHandleClickProps } from 'common/accessibility';
import { API_BASE, BASE_URL, COMMENT_NODE_CLASSNAME_PREFIX, BLOCKING_DURATIONS } from 'common/constants';
import { url } from 'common/settings';
import store from 'common/store';
import copy from 'common/copy';
import Input from 'components/input';
import Avatar from 'components/avatar-icon';
export default class Comment extends Component {
constructor(props) {
super(props);
this.state = {
isCopied: false,
isReplying: false,
isEditing: false,
isUserVerified: false,
editTimeLeft: null,
};
this.votingPromise = Promise.resolve();
this.updateState(props);
this.copyComment = this.copyComment.bind(this);
this.decreaseScore = this.decreaseScore.bind(this);
this.increaseScore = this.increaseScore.bind(this);
this.toggleEditing = this.toggleEditing.bind(this);
this.toggleReplying = this.toggleReplying.bind(this);
this.toggleCollapse = this.toggleCollapse.bind(this);
this.toggleUserInfoVisibility = this.toggleUserInfoVisibility.bind(this);
this.scrollToParent = this.scrollToParent.bind(this);
this.onEdit = this.onEdit.bind(this);
this.onReply = this.onReply.bind(this);
this.onDeleteClick = this.onDeleteClick.bind(this);
this.onOwnCommentDeleteClick = this.onOwnCommentDeleteClick.bind(this);
this.onBlockUserClick = this.onBlockUserClick.bind(this);
this.onUnblockUserClick = this.onUnblockUserClick.bind(this);
this.isAdmin = this.isAdmin.bind(this);
this.isCurrentUser = this.isCurrentUser.bind(this);
this.isGuest = this.isGuest.bind(this);
this.getUpvoteDisabledReason = this.getUpvoteDisabledReason.bind(this);
this.getDownvoteDisabledReason = this.getDownvoteDisabledReason.bind(this);
}
componentWillReceiveProps(nextProps) {
this.updateState(nextProps);
}
updateState(props) {
const {
data,
data: {
user: { block, id: commentUserId },
pin,
},
mods: { guest } = {},
} = props;
const votes = (data && data.votes) || [];
const score = (data && data.score) || 0;
if (this.editTimerInterval) {
clearInterval(this.editTimerInterval);
this.editTimerInterval = null;
}
if (guest) {
this.setState({
guest,
score,
deleted: data ? data.delete : false,
});
} else {
const userId = store.get('user').id;
this.setState({
guest,
score,
pinned: !!pin,
deleted: data ? data.delete : false,
userBlocked: !!block,
scoreIncreased: userId in votes && votes[userId],
scoreDecreased: userId in votes && !votes[userId],
});
if (userId === commentUserId) {
const editDuration = store.get('config') && store.get('config').edit_duration;
const timeDiff = store.get('serverClientTimeDiff') || 0;
const getEditTimeLeft = () => Math.floor(editDuration - ((new Date() - new Date(data.time)) / 1000 - timeDiff));
if (getEditTimeLeft() > 0) {
this.editTimerInterval = setInterval(() => {
const editTimeLeft = getEditTimeLeft();
if (editTimeLeft < 0) {
clearInterval(this.editTimerInterval);
this.editTimerInterval = null;
this.setState({ editTimeLeft: null });
} else {
this.setState({ editTimeLeft });
}
}, 1000);
}
}
}
}
toggleReplying() {
const { isReplying } = this.state;
const onPrevInputToggleCb = store.get('onPrevInputToggleCb');
this.setState({ isEditing: false }, () => this.setState({ isReplying: !isReplying }));
if (onPrevInputToggleCb) onPrevInputToggleCb();
if (!isReplying) {
store.set('onPrevInputToggleCb', () => this.setState({ isReplying: false }));
} else {
store.set('onPrevInputToggleCb', null);
}
}
toggleEditing() {
const { isEditing } = this.state;
const onPrevInputToggleCb = store.get('onPrevInputToggleCb');
this.setState({ isReplying: false }, () => this.setState({ isEditing: !isEditing }));
if (onPrevInputToggleCb) onPrevInputToggleCb();
if (!isEditing) {
store.set('onPrevInputToggleCb', () => this.setState({ isEditing: false }));
} else {
store.set('onPrevInputToggleCb', null);
}
}
toggleUserInfoVisibility() {
if (window.parent) {
const { user } = this.props.data;
const data = JSON.stringify({ isUserInfoShown: true, user });
window.parent.postMessage(data, '*');
}
}
togglePin(isPinned) {
const { id } = this.props.data;
const promptMessage = `Do you want to ${isPinned ? 'unpin' : 'pin'} this user?`;
if (confirm(promptMessage)) {
this.setState({ pinned: !isPinned });
(isPinned ? api.unpinComment : api.pinComment)({ id, url }).then(() => {
api.getComment({ id }).then(comment => store.replaceComment(comment));
});
}
}
toggleVerify(isVerified) {
const {
id,
user: { id: userId },
} = this.props.data;
const promptMessage = `Do you want to ${isVerified ? 'unverify' : 'verify'} this user?`;
if (confirm(promptMessage)) {
this.setState({ isUserVerified: !isVerified });
(isVerified ? api.removeVerifyStatus : api.setVerifyStatus)({ id: userId }).then(() => {
api.getComment({ id }).then(comment => store.replaceComment(comment));
});
}
}
onBlockUserClick(e) {
const {
id,
user: { id: userId },
} = this.props.data;
const ttl = e.target.value;
const duration = BLOCKING_DURATIONS.find(el => el.value === ttl).label;
const promptMessage =
ttl === 'permanently'
? 'Do you want to permanently block this user?'
: `Do you want to block this user (${duration.toLowerCase()})?`;
if (confirm(promptMessage)) {
this.setState({ userBlocked: true });
api
.blockUser({ id: userId, ttl })
.then(api.getComment({ id }))
.then(comment => store.replaceComment(comment));
}
}
onUnblockUserClick() {
const {
id,
user: { id: userId },
} = this.props.data;
const promptMessage = `Do you want to unblock this user?`;
if (confirm(promptMessage)) {
this.setState({ userBlocked: false });
api
.unblockUser({ id: userId })
.then(api.getComment({ id }))
.then(comment => store.replaceComment(comment));
}
}
onDeleteClick() {
const { id } = this.props.data;
if (confirm('Do you want to delete this comment?')) {
this.setState({
deleted: true,
isEditing: false,
isReplying: false,
});
api.removeComment({ id }).then(() => {
api.getComment({ id }).then(comment => store.replaceComment(comment));
});
}
}
onOwnCommentDeleteClick() {
const { id } = this.props.data;
if (confirm('Do you want to delete this comment?')) {
this.setState({
deleted: true,
isEditing: false,
isReplying: false,
});
api.removeMyComment({ id }).then(() => {
api.getComment({ id }).then(comment => store.replaceComment(comment));
});
}
}
increaseScore() {
const { score, scoreIncreased, scoreDecreased } = this.state;
const { id } = this.props.data;
if (scoreIncreased) return;
this.setState({
scoreIncreased: !scoreDecreased,
scoreDecreased: false,
score: score + 1,
});
this.votingPromise = this.votingPromise.then(() => {
return api.putCommentVote({ id, url, value: 1 }).then(() => {
api.getComment({ id }).then(comment => store.replaceComment(comment));
});
});
}
decreaseScore() {
const { score, scoreIncreased, scoreDecreased } = this.state;
const { id } = this.props.data;
if (scoreDecreased) return;
this.setState({
scoreDecreased: !scoreIncreased,
scoreIncreased: false,
score: score - 1,
});
this.votingPromise = this.votingPromise.then(() => {
return api.putCommentVote({ id, url, value: -1 }).then(() => {
api.getComment({ id }).then(comment => store.replaceComment(comment));
});
});
}
onReply(...rest) {
this.props.onReply(...rest);
this.setState({
isReplying: false,
});
}
onEdit(...rest) {
this.props.onEdit(...rest);
this.setState({
isEditing: false,
});
}
scrollToParent(e) {
const {
data: { pid },
} = this.props;
e.preventDefault();
const parentCommentNode = document.getElementById(`${COMMENT_NODE_CLASSNAME_PREFIX}${pid}`);
if (parentCommentNode) {
parentCommentNode.scrollIntoView();
}
}
toggleCollapse() {
this.setState({
isEditing: false,
isReplying: false,
});
if (this.props.onCollapseToggle) {
this.props.onCollapseToggle();
}
}
copyComment({ username, time }) {
const text = this.textNode.textContent;
copy(`<b>${username}</b>&nbsp;${time}<br>${text.replace(/\n+/g, '<br>')}`);
this.setState({ isCopied: true }, () => {
setTimeout(() => this.setState({ isCopied: false }), 3000);
});
}
isAdmin() {
return !this.state.guest && store.get('user').admin;
}
isGuest() {
return this.state.guest || !Object.keys(store.get('user')).length;
}
isCurrentUser() {
return (
(this.props.data && this.props.data.user && this.props.data.user.id) ===
(store.get('user') && store.get('user').id)
);
}
/**
* returns reason for disabled voting
*
* @return {(string|null)}
*/
getDownvoteDisabledReason() {
if (this.props.mods && this.props.mods.view === 'user') return 'Voting disabled in last comments';
if (this.isGuest()) return 'Only authorized users are allowed to vote';
const info = store.get('info');
if (info && info.read_only) return "You can't vote on read-only topics";
if (this.isCurrentUser()) return "You can't vote for your own comment";
const config = store.get('config') || {};
if (config.positive_score && this.state.score < 1) return 'Only positive score allowed';
return null;
}
/**
* returns reason for disabled voting
*
* @return {(string|null)}
*/
getUpvoteDisabledReason() {
if (this.props.mods && this.props.mods.view === 'user') return 'Voting disabled in last comments';
if (this.isGuest()) return 'Only authorized users are allowed to vote';
const info = store.get('info');
if (info && info.read_only) return "You can't vote on read-only topics";
if (this.isCurrentUser()) return "You can't vote for your own comment";
return null;
}
render(
props,
{
userBlocked,
pinned,
score,
scoreIncreased,
scoreDecreased,
deleted,
isCopied,
isReplying,
isEditing,
isUserVerified,
editTimeLeft,
}
) {
const { data, mods = {}, isCommentsDisabled } = props;
const isAdmin = this.isAdmin();
const isGuest = this.isGuest();
const isCurrentUser = this.isCurrentUser();
const config = store.get('config') || {};
const lowCommentScore = config.low_score;
const downvotingDisabledReason = this.getDownvoteDisabledReason();
const isDownvotingDisabled = downvotingDisabledReason !== null;
const upvotingDisabledReason = this.getUpvoteDisabledReason();
const isUpvotingDisabled = upvotingDisabledReason !== null;
const editable = data.repliesCount === 0 && !!editTimeLeft;
const scoreSignEnabled = !config.positive_score;
const o = {
...data,
text: data.text.length
? mods.view === 'preview'
? getTextSnippet(data.text)
: data.text
: userBlocked
? 'This user was blocked'
: deleted
? 'This comment was deleted'
: data.text,
time: formatTime(new Date(data.time)),
orig: isEditing
? data.orig &&
data.orig.replace(/&[#A-Za-z0-9]+;/gi, entity => {
const span = document.createElement('span');
span.innerHTML = entity;
return span.innerText;
})
: data.orig,
score: {
value: Math.abs(score),
sign: !scoreSignEnabled ? '' : score > 0 ? '+' : score < 0 ? '' : null,
view: score > 0 ? 'positive' : score < 0 ? 'negative' : null,
},
user: {
...data.user,
picture: data.user.picture.indexOf(API_BASE) === 0 ? `${BASE_URL}${data.user.picture}` : data.user.picture,
verified: data.user.verified || isUserVerified,
},
};
const defaultMods = {
pinned,
// TODO: we also have critical_score, so we need to collapse comments with it in future
useless: userBlocked || deleted || (score <= lowCommentScore && !mods.pinned && !mods.disabled),
// TODO: add default view mod or don't?
view: o.user.admin ? 'admin' : null,
replying: isReplying,
editing: isEditing,
};
if (mods.view === 'preview') {
return (
<article className={b('comment', props, 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: mods.theme }) })}
dangerouslySetInnerHTML={{ __html: o.text }}
/>
</div>
</article>
);
}
return (
<article
className={b('comment', props, defaultMods)}
id={mods.disabled ? null : `${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`}
>
{mods.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__body">
<div className="comment__info">
{mods.view !== 'user' && <Avatar picture={o.user.picture} />}
{mods.view !== 'user' && (
<span
{...getHandleClickProps(this.toggleUserInfoVisibility)}
className="comment__username"
title={o.user.id}
>
{o.user.name}
</span>
)}
{isAdmin &&
mods.view !== 'user' && (
<span
{...getHandleClickProps(() => this.toggleVerify(o.user.verified))}
aria-label="Toggle verification"
title={o.user.verified ? 'Verified user' : 'Unverified user'}
className={b('comment__verification', {}, { active: o.user.verified, clickable: true })}
/>
)}
{!isAdmin &&
!!o.user.verified &&
mods.view !== 'user' && (
<span title="Verified user" className={b('comment__verification', {}, { active: true })} />
)}
<a href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`} className="comment__time">
{o.time}
</a>
{mods.level > 0 &&
mods.view !== 'user' && (
<a
className="comment__link-to-parent"
href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.pid}`}
aria-label="Go to parent comment"
title="Go to parent comment"
onClick={this.scrollToParent}
>
{' '}
</a>
)}
{isAdmin && userBlocked && mods.view !== 'user' && <span className="comment__status">Blocked</span>}
{isAdmin && !userBlocked && deleted && <span className="comment__status">Deleted</span>}
{!mods.disabled &&
mods.view !== 'user' && (
<span
{...getHandleClickProps(this.toggleCollapse)}
className={b('comment__action', {}, { type: 'collapse', selected: mods.collapsed })}
>
{mods.collapsed ? '+' : ''}
</span>
)}
<span className={b('comment__score', {}, { view: o.score.view })}>
<span
className={b(
'comment__vote',
{},
{ type: 'up', selected: scoreIncreased, disabled: isUpvotingDisabled }
)}
aria-disabled={isUpvotingDisabled ? 'true' : 'false'}
{...getHandleClickProps(isUpvotingDisabled ? null : this.increaseScore)}
title={upvotingDisabledReason}
>
Vote up
</span>
<span className="comment__score-value">
{o.score.sign}
{o.score.value}
</span>
<span
className={b(
'comment__vote',
{},
{ type: 'down', selected: scoreDecreased, disabled: isDownvotingDisabled }
)}
aria-disabled={isDownvotingDisabled ? 'true' : 'false'}
{...getHandleClickProps(isDownvotingDisabled ? null : this.decreaseScore)}
title={downvotingDisabledReason}
>
Vote down
</span>
</span>
</div>
<div
className={b('comment__text', { mix: b('raw-content', {}, { theme: mods.theme }) })}
ref={r => (this.textNode = r)}
dangerouslySetInnerHTML={{ __html: o.text }}
/>
<div className="comment__actions">
{!deleted &&
!isCommentsDisabled &&
!mods.disabled &&
!isGuest &&
mods.view !== 'user' && (
<span {...getHandleClickProps(this.toggleReplying)} className="comment__action">
{isReplying ? 'Cancel' : 'Reply'}
</span>
)}
{!deleted &&
!mods.disabled &&
!!o.orig &&
isCurrentUser &&
(editable || isEditing) &&
mods.view !== 'user' && [
<span
{...getHandleClickProps(this.toggleEditing)}
className="comment__action comment__action_type_edit"
>
{isEditing ? 'Cancel' : 'Edit'}
</span>,
!isAdmin && (
<span
{...getHandleClickProps(this.onOwnCommentDeleteClick)}
className="comment__action comment__action_type_delete"
>
Delete
</span>
),
<span className="comment__edit-timer">{editTimeLeft && `${editTimeLeft}`}</span>,
]}
{!deleted &&
isAdmin && (
<span className="comment__controls">
{!isCopied && (
<span
{...getHandleClickProps(() => this.copyComment({ username: o.user.name, time: o.time }))}
className="comment__control"
>
Copy
</span>
)}
{isCopied && <span className="comment__control comment__control_view_inactive">Copied!</span>}
{mods.view !== 'user' && (
<span {...getHandleClickProps(() => this.togglePin(pinned))} className="comment__control">
{pinned ? 'Unpin' : 'Pin'}
</span>
)}
{userBlocked && (
<span {...getHandleClickProps(() => this.onUnblockUserClick())} className="comment__control">
Unblock
</span>
)}
{!userBlocked && (
<span className="comment__control comment__control_select-label">
Block
{/* eslint-disable jsx-a11y/no-onchange */}
<select className="comment__control_select" onChange={this.onBlockUserClick}>
<option disabled selected value>
{' '}
Blocking period{' '}
</option>
{BLOCKING_DURATIONS.map(block => <option value={block.value}>{block.label}</option>)}
</select>
</span>
)}
{!deleted && (
<span {...getHandleClickProps(this.onDeleteClick)} className="comment__control">
Delete
</span>
)}
</span>
)}
</div>
</div>
{isReplying &&
mods.view !== 'user' && (
<Input mix="comment__input" onSubmit={this.onReply} onCancel={this.toggleReplying} pid={o.id} />
)}
{isEditing &&
mods.view !== 'user' && (
<Input
mix="comment__input"
mods={{ mode: 'edit' }}
onSubmit={this.onEdit}
onCancel={this.toggleEditing}
id={o.id}
value={o.orig}
errorMessage={!editTimeLeft && 'Editing time has expired.'}
/>
)}
</article>
);
}
}
function getTextSnippet(html) {
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 formatTime(time) {
// 'ru-RU' adds a dot as a separator
const date = time.toLocaleDateString(['ru-RU'], { day: '2-digit', month: '2-digit', year: '2-digit' });
// do it manually because Intl API doesn't add leading zeros to hours; idk why
const hours = `0${time.getHours()}`.slice(-2);
const mins = `0${time.getMinutes()}`.slice(-2);
return `${date} at ${hours}:${mins}`;
}