Files
remark42/web/app/components/comment/comment.jsx
T
2018-07-01 13:04:42 +03:00

477 lines
14 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.
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}`;
}