477 lines
14 KiB
React
477 lines
14 KiB
React
import { h, Component } from 'preact';
|
||
|
||
import api from 'common/api';
|
||
import { API_BASE, BASE_URL, COMMENT_NODE_CLASSNAME_PREFIX } from 'common/constants';
|
||
import { url } from 'common/settings';
|
||
import store from 'common/store';
|
||
|
||
import Input from 'components/input';
|
||
import UserInfo from 'components/user-info';
|
||
|
||
export default class Comment extends Component {
|
||
constructor(props) {
|
||
super(props);
|
||
|
||
this.state = {
|
||
isReplying: false,
|
||
isEditing: false,
|
||
isUserVerified: false,
|
||
isUserInfoShown: false,
|
||
editTimeLeft: null,
|
||
};
|
||
|
||
this.votingPromise = Promise.resolve();
|
||
|
||
this.updateState(props);
|
||
|
||
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.togglePin = this.togglePin.bind(this);
|
||
this.toggleVerify = this.toggleVerify.bind(this);
|
||
this.toggleBlock = this.toggleBlock.bind(this);
|
||
this.onDeleteClick = this.onDeleteClick.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 getEditTimeLeft = () => Math.floor(editDuration - (new Date() - new Date(data.time)) / 1000);
|
||
|
||
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() {
|
||
this.setState({ isUserInfoShown: !this.state.isUserInfoShown });
|
||
}
|
||
|
||
togglePin(isPinned) {
|
||
const { id } = this.props.data;
|
||
const promptMessage = isPinned ? 'Do you want to pin this comment?' : 'Do you want to pin this comment?';
|
||
|
||
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 = isVerified ? 'Do you want to verify this user?' : 'Do you want to 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));
|
||
}
|
||
}
|
||
|
||
toggleBlock(isBlocked) {
|
||
const { id, user: { id: userId } } = this.props.data;
|
||
const promptMessage = isBlocked ? 'Do you want to unblock this user?' : 'Do you want to block this user?';
|
||
|
||
if (confirm(promptMessage)) {
|
||
this.setState({ userBlocked: !isBlocked });
|
||
|
||
(isBlocked ? api.unblockUser : api.blockUser)({ 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));
|
||
});
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|
||
|
||
render(props, {
|
||
guest,
|
||
userBlocked,
|
||
pinned,
|
||
score,
|
||
scoreIncreased,
|
||
scoreDecreased,
|
||
deleted,
|
||
isReplying,
|
||
isEditing,
|
||
isUserVerified,
|
||
editTimeLeft,
|
||
isUserInfoShown,
|
||
}) {
|
||
const { data, mods = {} } = props;
|
||
const isAdmin = !guest && store.get('user').admin;
|
||
const isGuest = guest || !Object.keys(store.get('user')).length;
|
||
const isCurrentUser = (data.user && data.user.id) === (store.get('user') && store.get('user').id);
|
||
const config = store.get('config') || {};
|
||
const lowCommentScore = config.low_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: 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,
|
||
isDefaultPicture: !data.user.picture.length,
|
||
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">
|
||
<div className="comment__info">
|
||
<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: 'raw-content' })}
|
||
dangerouslySetInnerHTML={{ __html: o.text }}
|
||
/>
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<article className={b('comment', props, defaultMods)} id={mods.disabled ? null : `${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`}>
|
||
<div className="comment__body">
|
||
|
||
|
||
<div
|
||
className={b('comment__text', { mix: 'raw-content' })}
|
||
dangerouslySetInnerHTML={{ __html: o.text }}
|
||
/>
|
||
|
||
<div className="comment__actions">
|
||
{
|
||
!deleted && !mods.disabled && !isGuest && mods.view !== 'user' && (
|
||
<span
|
||
className="comment__action"
|
||
role="button"
|
||
tabIndex="0"
|
||
onClick={this.toggleReplying}
|
||
>{isReplying ? 'Cancel' : 'Reply'}</span>
|
||
)
|
||
}
|
||
|
||
{
|
||
!deleted && !mods.disabled && !!o.orig && isCurrentUser && (!!editTimeLeft || isEditing) && mods.view !== 'user' && (
|
||
<span
|
||
className="comment__action comment__action_type_edit"
|
||
role="button"
|
||
tabIndex="0"
|
||
onClick={this.toggleEditing}
|
||
>{isEditing ? 'Cancel' : 'Edit'}{editTimeLeft && ` (${editTimeLeft})`}</span>
|
||
)
|
||
}
|
||
|
||
{
|
||
!deleted && isAdmin && (
|
||
<span className="comment__controls">
|
||
<span
|
||
className="comment__control"
|
||
role="button"
|
||
tabIndex="0"
|
||
onClick={() => this.togglePin(pinned)}
|
||
>{pinned ? 'Unpin' : 'Pin'}</span>
|
||
|
||
<span
|
||
className="comment__control"
|
||
role="button"
|
||
tabIndex="0"
|
||
onClick={() => this.toggleBlock(userBlocked)}
|
||
>{userBlocked ? 'Unblock' : 'Block'}</span>
|
||
|
||
{
|
||
!deleted && (
|
||
<span
|
||
className="comment__control"
|
||
role="button"
|
||
tabIndex="0"
|
||
onClick={this.onDeleteClick}
|
||
>Delete</span>
|
||
)
|
||
}
|
||
</span>
|
||
)
|
||
}
|
||
</div>
|
||
</div>
|
||
|
||
{
|
||
isUserInfoShown && (
|
||
<UserInfo mix="comment__user-info" user={o.user} onClose={this.toggleUserInfoVisibility}/>
|
||
)
|
||
}
|
||
|
||
{
|
||
isReplying && mods.view !== 'user' && (
|
||
<Input
|
||
mix="comment__input"
|
||
onSubmit={this.onReply}
|
||
onCancel={this.toggleReplying}
|
||
pid={o.id}
|
||
autoFocus
|
||
/>
|
||
)
|
||
}
|
||
|
||
{
|
||
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.'}
|
||
autoFocus
|
||
/>
|
||
)
|
||
}
|
||
</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}`;
|
||
}
|