/** @jsx createElement */
import './styles';
import { createElement, JSX, Component, createRef, ComponentType } from 'preact';
import b from 'bem-react-helper';
import { getHandleClickProps } from '@app/common/accessibility';
import { API_BASE, BASE_URL, COMMENT_NODE_CLASSNAME_PREFIX } from '@app/common/constants';
import { StaticStore } from '@app/common/static_store';
import debounce from '@app/utils/debounce';
import copy from '@app/common/copy';
import { Theme, BlockTTL, Comment as CommentType, PostInfo, User, CommentMode } from '@app/common/types';
import { extractErrorMessageFromResponse, FetcherError } from '@app/utils/errorUtils';
import { isUserAnonymous } from '@app/utils/isUserAnonymous';
import { Props as CommentFormProps } from '@app/components/comment-form';
import { AvatarIcon } from '@app/components/avatar-icon';
import { Button } from '@app/components/button';
import Countdown from '@app/components/countdown';
import { boundActions } from './connected-comment';
import { getPreview, uploadImage } from '@app/common/api';
import postMessage from '@app/utils/postMessage';
import { FormattedMessage, useIntl, IntlShape, defineMessages } from 'react-intl';
import { getVoteMessage, VoteMessagesTypes } from './getVoteMessage';
import { getBlockingDurations } from './getBlockingDurations';
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.',
},
});
type PropsWithoutIntl = {
user: User | null;
CommentForm: ComponentType | null;
data: CommentType;
repliesCount?: number;
post_info: PostInfo | null;
/** 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;
} & Partial;
export type Props = PropsWithoutIntl & { intl: IntlShape };
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;
}
class Comment extends Component {
votingPromise: Promise = Promise.resolve();
/** comment text node. Used in comment text copying */
textNode = createRef();
updateState = (props: Props) => {
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: Props) {
this.setState(this.updateState(nextProps));
}
componentDidMount() {
this.setState({ initial: false });
}
toggleReplying = () => {
const { editMode } = this.props;
if (editMode === CommentMode.Reply) {
this.props.setReplyEditState!({ id: this.props.data.id, state: CommentMode.None });
} else {
this.props.setReplyEditState!({ id: this.props.data.id, state: CommentMode.Reply });
}
};
toggleEditing = () => {
const { editMode } = this.props;
if (editMode === CommentMode.Edit) {
this.props.setReplyEditState!({ id: this.props.data.id, state: CommentMode.None });
} else {
this.props.setReplyEditState!({ id: this.props.data.id, state: CommentMode.Edit });
}
};
toggleUserInfoVisibility = () => {
if (window.parent) {
const { user } = this.props.data;
const data = JSON.stringify({ isUserInfoShown: true, user });
window.parent.postMessage(data, '*');
}
};
togglePin = () => {
const value = !this.props.data.pin;
const intl = this.props.intl;
const promptMessage = value ? intl.formatMessage(messages.pinComment) : intl.formatMessage(messages.unpinComment);
if (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 (confirm(promptMessage)) {
this.props.setVerifiedStatus!(userId, value);
}
};
onBlockUserClick = (e: Event) => {
// 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') {
(e.target as HTMLElement).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((e.target as HTMLOptionElement).value as BlockTTL);
};
blockUser = debounce((ttl: BlockTTL) => {
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 (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 (confirm(unblockUser)) {
this.props.unblockUser!(user.id);
}
};
deleteComment = () => {
const deleteComment = this.props.intl.formatMessage(messages.deleteMessage);
if (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 (!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 = (e: Event) => {
const {
data: { pid },
} = this.props;
e.preventDefault();
const parentCommentNode = document.getElementById(`${COMMENT_NODE_CLASSNAME_PREFIX}${pid}`);
if (parentCommentNode) {
const top = parentCommentNode.getBoundingClientRect().top;
if (!postMessage({ scrollTo: top })) {
parentCommentNode.scrollIntoView();
}
}
};
copyComment = () => {
const username = this.props.data.user.name;
const time = 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: Props, 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;
/**
* 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' : null,
},
user: {
...props.data.user,
picture:
props.data.user.picture.indexOf(API_BASE) === 0
? `${BASE_URL}${props.data.user.picture}`
: props.data.user.picture,
},
};
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' ? null : 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' && (
{o.user.name}
)}
{isAdmin && props.view !== 'user' && (
)}
{!isAdmin && !!o.user.verified && props.view !== 'user' && (
)}
{!!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 FormatTime({ time }: { time: Date }) {
const intl = useIntl();
return (
);
}
const CommentWithIntl = (props: PropsWithoutIntl) => {
const intl = useIntl();
return ;
};
export { CommentWithIntl as Comment };