1 import { Component, linkEvent } from 'inferno';
2 import { Link } from 'inferno-router';
4 CommentNode as CommentNodeI,
6 CommentForm as CommentFormI,
13 AddModToCommunityForm,
15 TransferCommunityForm,
20 } from '../interfaces';
21 import { WebSocketService, UserService } from '../services';
30 import moment from 'moment';
31 import { MomentTime } from './moment-time';
32 import { CommentForm } from './comment-form';
33 import { CommentNodes } from './comment-nodes';
34 import { UserListing } from './user-listing';
35 import { i18n } from '../i18next';
37 interface CommentNodeState {
40 showRemoveDialog: boolean;
42 showBanDialog: boolean;
46 showConfirmTransferSite: boolean;
47 showConfirmTransferCommunity: boolean;
48 showConfirmAppointAsMod: boolean;
49 showConfirmAppointAsAdmin: boolean;
52 showAdvanced: boolean;
62 interface CommentNodeProps {
68 showContext?: boolean;
69 moderators: Array<CommunityUser>;
70 admins: Array<UserView>;
71 // TODO is this necessary, can't I get it from the node itself?
72 postCreatorId?: number;
73 showCommunity?: boolean;
74 sort?: CommentSortType;
78 export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
79 private emptyState: CommentNodeState = {
82 showRemoveDialog: false,
87 banType: BanType.Community,
91 showConfirmTransferSite: false,
92 showConfirmTransferCommunity: false,
93 showConfirmAppointAsMod: false,
94 showConfirmAppointAsAdmin: false,
95 my_vote: this.props.node.comment.my_vote,
96 score: this.props.node.comment.score,
97 upvotes: this.props.node.comment.upvotes,
98 downvotes: this.props.node.comment.downvotes,
99 borderColor: this.props.node.comment.depth
100 ? colorList[this.props.node.comment.depth % colorList.length]
106 constructor(props: any, context: any) {
107 super(props, context);
109 this.state = this.emptyState;
110 this.handleReplyCancel = this.handleReplyCancel.bind(this);
111 this.handleCommentUpvote = this.handleCommentUpvote.bind(this);
112 this.handleCommentDownvote = this.handleCommentDownvote.bind(this);
115 componentWillReceiveProps(nextProps: CommentNodeProps) {
116 this.state.my_vote = nextProps.node.comment.my_vote;
117 this.state.upvotes = nextProps.node.comment.upvotes;
118 this.state.downvotes = nextProps.node.comment.downvotes;
119 this.state.score = nextProps.node.comment.score;
120 this.state.readLoading = false;
121 this.state.saveLoading = false;
122 this.setState(this.state);
126 let node = this.props.node;
129 className={`comment ${
130 node.comment.parent_id && !this.props.noIndent ? 'ml-1' : ''
134 id={`comment-${node.comment.id}`}
135 className={`details comment-node border-top border-light ${
136 this.isCommentNew ? 'mark' : ''
139 !this.props.noIndent &&
140 this.props.node.comment.parent_id &&
141 `border-left: 2px ${this.state.borderColor} solid !important`
146 !this.props.noIndent &&
147 this.props.node.comment.parent_id &&
151 <div class="d-flex flex-wrap align-items-center mb-1 mt-1 text-muted small">
155 name: node.comment.creator_name,
156 avatar: node.comment.creator_avatar,
161 <div className="badge badge-light d-none d-sm-inline mr-2">
166 <div className="badge badge-light d-none d-sm-inline mr-2">
170 {this.isPostCreator && (
171 <div className="badge badge-light d-none d-sm-inline mr-2">
175 {(node.comment.banned_from_community || node.comment.banned) && (
176 <div className="badge badge-danger mr-2">
180 {this.props.showCommunity && (
182 <span class="mx-1">{i18n.t('to')}</span>
183 <Link class="mr-2" to={`/c/${node.comment.community_name}`}>
184 {node.comment.community_name}
189 className="mr-lg-4 flex-grow-1 flex-lg-grow-0 unselectable pointer mx-2"
190 onClick={linkEvent(this, this.handleCommentCollapse)}
192 {this.state.collapsed ? (
193 <svg class="icon icon-inline">
194 <use xlinkHref="#icon-plus-square"></use>
197 <svg class="icon icon-inline">
198 <use xlinkHref="#icon-minus-square"></use>
203 className={`unselectable pointer ${this.scoreColor}`}
204 onClick={linkEvent(node, this.handleCommentUpvote)}
205 data-tippy-content={this.pointsTippy}
207 <svg class="icon icon-inline mr-1">
208 <use xlinkHref="#icon-zap"></use>
210 <span class="mr-1">{this.state.score}</span>
212 <span className="mr-1">•</span>
214 <MomentTime data={node.comment} />
217 {/* end of user row */}
218 {this.state.showEdit && (
222 onReplyCancel={this.handleReplyCancel}
223 disabled={this.props.locked}
226 {!this.state.showEdit && !this.state.collapsed && (
228 {this.state.viewSource ? (
229 <pre>{this.commentUnlessRemoved}</pre>
233 dangerouslySetInnerHTML={mdToHtml(
234 this.commentUnlessRemoved
238 <div class="d-flex justify-content-between justify-content-lg-start flex-wrap text-muted font-weight-bold">
239 {this.props.showContext && this.linkBtn}
240 {this.props.markable && (
242 class="btn btn-link btn-animate text-muted"
243 onClick={linkEvent(this, this.handleMarkRead)}
246 ? i18n.t('mark_as_unread')
247 : i18n.t('mark_as_read')
250 {this.state.readLoading ? (
254 class={`icon icon-inline ${
255 node.comment.read && 'text-success'
258 <use xlinkHref="#icon-check"></use>
263 {UserService.Instance.user && !this.props.viewOnly && (
266 className={`btn btn-link btn-animate ${
267 this.state.my_vote == 1 ? 'text-info' : 'text-muted'
269 onClick={linkEvent(node, this.handleCommentUpvote)}
270 data-tippy-content={i18n.t('upvote')}
272 <svg class="icon icon-inline">
273 <use xlinkHref="#icon-arrow-up"></use>
275 {this.state.upvotes !== this.state.score && (
276 <span class="ml-1">{this.state.upvotes}</span>
279 {WebSocketService.Instance.site.enable_downvotes && (
281 className={`btn btn-link btn-animate ${
282 this.state.my_vote == -1
286 onClick={linkEvent(node, this.handleCommentDownvote)}
287 data-tippy-content={i18n.t('downvote')}
289 <svg class="icon icon-inline">
290 <use xlinkHref="#icon-arrow-down"></use>
292 {this.state.upvotes !== this.state.score && (
293 <span class="ml-1">{this.state.downvotes}</span>
298 class="btn btn-link btn-animate text-muted"
299 onClick={linkEvent(this, this.handleSaveCommentClick)}
301 node.comment.saved ? i18n.t('unsave') : i18n.t('save')
304 {this.state.saveLoading ? (
308 class={`icon icon-inline ${
309 node.comment.saved && 'text-warning'
312 <use xlinkHref="#icon-star"></use>
317 class="btn btn-link btn-animate text-muted"
318 onClick={linkEvent(this, this.handleReplyClick)}
319 data-tippy-content={i18n.t('reply')}
321 <svg class="icon icon-inline">
322 <use xlinkHref="#icon-reply1"></use>
325 {!this.state.showAdvanced ? (
327 className="btn btn-link btn-animate text-muted"
328 onClick={linkEvent(this, this.handleShowAdvanced)}
329 data-tippy-content={i18n.t('more')}
331 <svg class="icon icon-inline">
332 <use xlinkHref="#icon-more-vertical"></use>
337 {!this.myComment && (
338 <button class="btn btn-link btn-animate">
341 to={`/create_private_message?recipient_id=${node.comment.creator_id}`}
342 title={i18n.t('message').toLowerCase()}
345 <use xlinkHref="#icon-mail"></use>
350 {!this.props.showContext && this.linkBtn}
352 className="btn btn-link btn-animate text-muted"
353 onClick={linkEvent(this, this.handleViewSource)}
354 data-tippy-content={i18n.t('view_source')}
357 class={`icon icon-inline ${
358 this.state.viewSource && 'text-success'
361 <use xlinkHref="#icon-file-text"></use>
367 class="btn btn-link btn-animate text-muted"
368 onClick={linkEvent(this, this.handleEditClick)}
369 data-tippy-content={i18n.t('edit')}
371 <svg class="icon icon-inline">
372 <use xlinkHref="#icon-edit"></use>
376 class="btn btn-link btn-animate text-muted"
379 this.handleDeleteClick
382 !node.comment.deleted
388 class={`icon icon-inline ${
389 node.comment.deleted && 'text-danger'
392 <use xlinkHref="#icon-trash"></use>
397 {/* Admins and mods can remove comments */}
398 {(this.canMod || this.canAdmin) && (
400 {!node.comment.removed ? (
402 class="btn btn-link btn-animate text-muted"
405 this.handleModRemoveShow
412 class="btn btn-link btn-animate text-muted"
415 this.handleModRemoveSubmit
423 {/* Mods can ban from community, and appoint as mods to community */}
427 (!node.comment.banned_from_community ? (
429 class="btn btn-link btn-animate text-muted"
432 this.handleModBanFromCommunityShow
439 class="btn btn-link btn-animate text-muted"
442 this.handleModBanFromCommunitySubmit
448 {!node.comment.banned_from_community &&
449 (!this.state.showConfirmAppointAsMod ? (
451 class="btn btn-link btn-animate text-muted"
454 this.handleShowConfirmAppointAsMod
458 ? i18n.t('remove_as_mod')
459 : i18n.t('appoint_as_mod')}
463 <button class="btn btn-link btn-animate text-muted">
464 {i18n.t('are_you_sure')}
467 class="btn btn-link btn-animate text-muted"
470 this.handleAddModToCommunity
476 class="btn btn-link btn-animate text-muted"
479 this.handleCancelConfirmAppointAsMod
488 {/* Community creators and admins can transfer community to another mod */}
489 {(this.amCommunityCreator || this.canAdmin) &&
491 (!this.state.showConfirmTransferCommunity ? (
493 class="btn btn-link btn-animate text-muted"
496 this.handleShowConfirmTransferCommunity
499 {i18n.t('transfer_community')}
503 <button class="btn btn-link btn-animate text-muted">
504 {i18n.t('are_you_sure')}
507 class="btn btn-link btn-animate text-muted"
510 this.handleTransferCommunity
516 class="btn btn-link btn-animate text-muted"
520 .handleCancelShowConfirmTransferCommunity
527 {/* Admins can ban from all, and appoint other admins */}
531 (!node.comment.banned ? (
533 class="btn btn-link btn-animate text-muted"
536 this.handleModBanShow
539 {i18n.t('ban_from_site')}
543 class="btn btn-link btn-animate text-muted"
546 this.handleModBanSubmit
549 {i18n.t('unban_from_site')}
552 {!node.comment.banned &&
553 (!this.state.showConfirmAppointAsAdmin ? (
555 class="btn btn-link btn-animate text-muted"
558 this.handleShowConfirmAppointAsAdmin
562 ? i18n.t('remove_as_admin')
563 : i18n.t('appoint_as_admin')}
567 <button class="btn btn-link btn-animate text-muted">
568 {i18n.t('are_you_sure')}
571 class="btn btn-link btn-animate text-muted"
580 class="btn btn-link btn-animate text-muted"
583 this.handleCancelConfirmAppointAsAdmin
592 {/* Site Creator can transfer to another admin */}
593 {this.amSiteCreator &&
595 (!this.state.showConfirmTransferSite ? (
597 class="btn btn-link btn-animate text-muted"
600 this.handleShowConfirmTransferSite
603 {i18n.t('transfer_site')}
607 <button class="btn btn-link btn-animate text-muted">
608 {i18n.t('are_you_sure')}
611 class="btn btn-link btn-animate text-muted"
614 this.handleTransferSite
620 class="btn btn-link btn-animate text-muted"
623 this.handleCancelShowConfirmTransferSite
635 {/* end of button group */}
640 {/* end of details */}
641 {this.state.showRemoveDialog && (
644 onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
648 class="form-control mr-2"
649 placeholder={i18n.t('reason')}
650 value={this.state.removeReason}
651 onInput={linkEvent(this, this.handleModRemoveReasonChange)}
653 <button type="submit" class="btn btn-secondary">
654 {i18n.t('remove_comment')}
658 {this.state.showBanDialog && (
659 <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
660 <div class="form-group row">
661 <label class="col-form-label">{i18n.t('reason')}</label>
664 class="form-control mr-2"
665 placeholder={i18n.t('reason')}
666 value={this.state.banReason}
667 onInput={linkEvent(this, this.handleModBanReasonChange)}
670 {/* TODO hold off on expires until later */}
671 {/* <div class="form-group row"> */}
672 {/* <label class="col-form-label">Expires</label> */}
673 {/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
675 <div class="form-group row">
676 <button type="submit" class="btn btn-secondary">
677 {i18n.t('ban')} {node.comment.creator_name}
682 {this.state.showReply && (
685 onReplyCancel={this.handleReplyCancel}
686 disabled={this.props.locked}
689 {node.children && !this.state.collapsed && (
691 nodes={node.children}
692 locked={this.props.locked}
693 moderators={this.props.moderators}
694 admins={this.props.admins}
695 postCreatorId={this.props.postCreatorId}
696 sort={this.props.sort}
697 sortType={this.props.sortType}
700 {/* A collapsed clearfix */}
701 {this.state.collapsed && <div class="row col-12"></div>}
707 let node = this.props.node;
709 <button className="btn btn-link btn-animate">
712 to={`/post/${node.comment.post_id}/comment/${node.comment.id}`}
714 this.props.showContext ? i18n.t('show_context') : i18n.t('link')
717 <svg class="icon icon-inline">
718 <use xlinkHref="#icon-link"></use>
727 <svg class="icon icon-spinner spin">
728 <use xlinkHref="#icon-spinner"></use>
733 get myComment(): boolean {
735 UserService.Instance.user &&
736 this.props.node.comment.creator_id == UserService.Instance.user.id
740 get isMod(): boolean {
742 this.props.moderators &&
744 this.props.moderators.map(m => m.user_id),
745 this.props.node.comment.creator_id
750 get isAdmin(): boolean {
754 this.props.admins.map(a => a.id),
755 this.props.node.comment.creator_id
760 get isPostCreator(): boolean {
761 return this.props.node.comment.creator_id == this.props.postCreatorId;
764 get canMod(): boolean {
765 if (this.props.admins && this.props.moderators) {
766 let adminsThenMods = this.props.admins
768 .concat(this.props.moderators.map(m => m.user_id));
771 UserService.Instance.user,
773 this.props.node.comment.creator_id
780 get canAdmin(): boolean {
784 UserService.Instance.user,
785 this.props.admins.map(a => a.id),
786 this.props.node.comment.creator_id
791 get amCommunityCreator(): boolean {
793 this.props.moderators &&
794 UserService.Instance.user &&
795 this.props.node.comment.creator_id != UserService.Instance.user.id &&
796 UserService.Instance.user.id == this.props.moderators[0].user_id
800 get amSiteCreator(): boolean {
803 UserService.Instance.user &&
804 this.props.node.comment.creator_id != UserService.Instance.user.id &&
805 UserService.Instance.user.id == this.props.admins[0].id
809 get commentUnlessRemoved(): string {
810 let node = this.props.node;
811 return node.comment.removed
812 ? `*${i18n.t('removed')}*`
813 : node.comment.deleted
814 ? `*${i18n.t('deleted')}*`
815 : node.comment.content;
818 handleReplyClick(i: CommentNode) {
819 i.state.showReply = true;
823 handleEditClick(i: CommentNode) {
824 i.state.showEdit = true;
828 handleDeleteClick(i: CommentNode) {
829 let deleteForm: CommentFormI = {
830 content: i.props.node.comment.content,
831 edit_id: i.props.node.comment.id,
832 creator_id: i.props.node.comment.creator_id,
833 post_id: i.props.node.comment.post_id,
834 parent_id: i.props.node.comment.parent_id,
835 deleted: !i.props.node.comment.deleted,
838 WebSocketService.Instance.editComment(deleteForm);
841 handleSaveCommentClick(i: CommentNode) {
843 i.props.node.comment.saved == undefined
845 : !i.props.node.comment.saved;
846 let form: SaveCommentForm = {
847 comment_id: i.props.node.comment.id,
851 WebSocketService.Instance.saveComment(form);
853 i.state.saveLoading = true;
854 i.setState(this.state);
857 handleReplyCancel() {
858 this.state.showReply = false;
859 this.state.showEdit = false;
860 this.setState(this.state);
863 handleCommentUpvote(i: CommentNodeI) {
864 let new_vote = this.state.my_vote == 1 ? 0 : 1;
866 if (this.state.my_vote == 1) {
868 this.state.upvotes--;
869 } else if (this.state.my_vote == -1) {
870 this.state.downvotes--;
871 this.state.upvotes++;
872 this.state.score += 2;
874 this.state.upvotes++;
878 this.state.my_vote = new_vote;
880 let form: CommentLikeForm = {
881 comment_id: i.comment.id,
882 post_id: i.comment.post_id,
883 score: this.state.my_vote,
886 WebSocketService.Instance.likeComment(form);
887 this.setState(this.state);
891 handleCommentDownvote(i: CommentNodeI) {
892 let new_vote = this.state.my_vote == -1 ? 0 : -1;
894 if (this.state.my_vote == 1) {
895 this.state.score -= 2;
896 this.state.upvotes--;
897 this.state.downvotes++;
898 } else if (this.state.my_vote == -1) {
899 this.state.downvotes--;
902 this.state.downvotes++;
906 this.state.my_vote = new_vote;
908 let form: CommentLikeForm = {
909 comment_id: i.comment.id,
910 post_id: i.comment.post_id,
911 score: this.state.my_vote,
914 WebSocketService.Instance.likeComment(form);
915 this.setState(this.state);
919 handleModRemoveShow(i: CommentNode) {
920 i.state.showRemoveDialog = true;
924 handleModRemoveReasonChange(i: CommentNode, event: any) {
925 i.state.removeReason = event.target.value;
929 handleModRemoveSubmit(i: CommentNode) {
930 event.preventDefault();
931 let form: CommentFormI = {
932 content: i.props.node.comment.content,
933 edit_id: i.props.node.comment.id,
934 creator_id: i.props.node.comment.creator_id,
935 post_id: i.props.node.comment.post_id,
936 parent_id: i.props.node.comment.parent_id,
937 removed: !i.props.node.comment.removed,
938 reason: i.state.removeReason,
941 WebSocketService.Instance.editComment(form);
943 i.state.showRemoveDialog = false;
947 handleMarkRead(i: CommentNode) {
948 // if it has a user_mention_id field, then its a mention
949 if (i.props.node.comment.user_mention_id) {
950 let form: EditUserMentionForm = {
951 user_mention_id: i.props.node.comment.user_mention_id,
952 read: !i.props.node.comment.read,
954 WebSocketService.Instance.editUserMention(form);
956 let form: CommentFormI = {
957 content: i.props.node.comment.content,
958 edit_id: i.props.node.comment.id,
959 creator_id: i.props.node.comment.creator_id,
960 post_id: i.props.node.comment.post_id,
961 parent_id: i.props.node.comment.parent_id,
962 read: !i.props.node.comment.read,
965 WebSocketService.Instance.editComment(form);
968 i.state.readLoading = true;
969 i.setState(this.state);
972 handleModBanFromCommunityShow(i: CommentNode) {
973 i.state.showBanDialog = !i.state.showBanDialog;
974 i.state.banType = BanType.Community;
978 handleModBanShow(i: CommentNode) {
979 i.state.showBanDialog = !i.state.showBanDialog;
980 i.state.banType = BanType.Site;
984 handleModBanReasonChange(i: CommentNode, event: any) {
985 i.state.banReason = event.target.value;
989 handleModBanExpiresChange(i: CommentNode, event: any) {
990 i.state.banExpires = event.target.value;
994 handleModBanFromCommunitySubmit(i: CommentNode) {
995 i.state.banType = BanType.Community;
997 i.handleModBanBothSubmit(i);
1000 handleModBanSubmit(i: CommentNode) {
1001 i.state.banType = BanType.Site;
1002 i.setState(i.state);
1003 i.handleModBanBothSubmit(i);
1006 handleModBanBothSubmit(i: CommentNode) {
1007 event.preventDefault();
1009 if (i.state.banType == BanType.Community) {
1010 let form: BanFromCommunityForm = {
1011 user_id: i.props.node.comment.creator_id,
1012 community_id: i.props.node.comment.community_id,
1013 ban: !i.props.node.comment.banned_from_community,
1014 reason: i.state.banReason,
1015 expires: getUnixTime(i.state.banExpires),
1017 WebSocketService.Instance.banFromCommunity(form);
1019 let form: BanUserForm = {
1020 user_id: i.props.node.comment.creator_id,
1021 ban: !i.props.node.comment.banned,
1022 reason: i.state.banReason,
1023 expires: getUnixTime(i.state.banExpires),
1025 WebSocketService.Instance.banUser(form);
1028 i.state.showBanDialog = false;
1029 i.setState(i.state);
1032 handleShowConfirmAppointAsMod(i: CommentNode) {
1033 i.state.showConfirmAppointAsMod = true;
1034 i.setState(i.state);
1037 handleCancelConfirmAppointAsMod(i: CommentNode) {
1038 i.state.showConfirmAppointAsMod = false;
1039 i.setState(i.state);
1042 handleAddModToCommunity(i: CommentNode) {
1043 let form: AddModToCommunityForm = {
1044 user_id: i.props.node.comment.creator_id,
1045 community_id: i.props.node.comment.community_id,
1048 WebSocketService.Instance.addModToCommunity(form);
1049 i.state.showConfirmAppointAsMod = false;
1050 i.setState(i.state);
1053 handleShowConfirmAppointAsAdmin(i: CommentNode) {
1054 i.state.showConfirmAppointAsAdmin = true;
1055 i.setState(i.state);
1058 handleCancelConfirmAppointAsAdmin(i: CommentNode) {
1059 i.state.showConfirmAppointAsAdmin = false;
1060 i.setState(i.state);
1063 handleAddAdmin(i: CommentNode) {
1064 let form: AddAdminForm = {
1065 user_id: i.props.node.comment.creator_id,
1068 WebSocketService.Instance.addAdmin(form);
1069 i.state.showConfirmAppointAsAdmin = false;
1070 i.setState(i.state);
1073 handleShowConfirmTransferCommunity(i: CommentNode) {
1074 i.state.showConfirmTransferCommunity = true;
1075 i.setState(i.state);
1078 handleCancelShowConfirmTransferCommunity(i: CommentNode) {
1079 i.state.showConfirmTransferCommunity = false;
1080 i.setState(i.state);
1083 handleTransferCommunity(i: CommentNode) {
1084 let form: TransferCommunityForm = {
1085 community_id: i.props.node.comment.community_id,
1086 user_id: i.props.node.comment.creator_id,
1088 WebSocketService.Instance.transferCommunity(form);
1089 i.state.showConfirmTransferCommunity = false;
1090 i.setState(i.state);
1093 handleShowConfirmTransferSite(i: CommentNode) {
1094 i.state.showConfirmTransferSite = true;
1095 i.setState(i.state);
1098 handleCancelShowConfirmTransferSite(i: CommentNode) {
1099 i.state.showConfirmTransferSite = false;
1100 i.setState(i.state);
1103 handleTransferSite(i: CommentNode) {
1104 let form: TransferSiteForm = {
1105 user_id: i.props.node.comment.creator_id,
1107 WebSocketService.Instance.transferSite(form);
1108 i.state.showConfirmTransferSite = false;
1109 i.setState(i.state);
1112 get isCommentNew(): boolean {
1113 let now = moment.utc().subtract(10, 'minutes');
1114 let then = moment.utc(this.props.node.comment.published);
1115 return now.isBefore(then);
1118 handleCommentCollapse(i: CommentNode) {
1119 i.state.collapsed = !i.state.collapsed;
1120 i.setState(i.state);
1123 handleViewSource(i: CommentNode) {
1124 i.state.viewSource = !i.state.viewSource;
1125 i.setState(i.state);
1128 handleShowAdvanced(i: CommentNode) {
1129 i.state.showAdvanced = !i.state.showAdvanced;
1130 i.setState(i.state);
1135 if (this.state.my_vote == 1) {
1137 } else if (this.state.my_vote == -1) {
1138 return 'text-danger';
1140 return 'text-muted';
1144 get pointsTippy(): string {
1145 let points = i18n.t('number_of_points', {
1146 count: this.state.score,
1149 let upvotes = i18n.t('number_of_upvotes', {
1150 count: this.state.upvotes,
1153 let downvotes = i18n.t('number_of_downvotes', {
1154 count: this.state.downvotes,
1157 return `${points} • ${upvotes} • ${downvotes}`;