1 import { Component, linkEvent } from 'inferno';
2 import { Link } from 'inferno-router';
4 CommentNode as CommentNodeI,
9 MarkUserMentionAsReadForm,
15 AddModToCommunityForm,
17 TransferCommunityForm,
22 } from '../interfaces';
23 import { WebSocketService, UserService } from '../services';
32 import moment from 'moment';
33 import { MomentTime } from './moment-time';
34 import { CommentForm } from './comment-form';
35 import { CommentNodes } from './comment-nodes';
36 import { UserListing } from './user-listing';
37 import { CommunityLink } from './community-link';
38 import { i18n } from '../i18next';
40 interface CommentNodeState {
43 showRemoveDialog: boolean;
45 showBanDialog: boolean;
49 showConfirmTransferSite: boolean;
50 showConfirmTransferCommunity: boolean;
51 showConfirmAppointAsMod: boolean;
52 showConfirmAppointAsAdmin: boolean;
55 showAdvanced: boolean;
65 interface CommentNodeProps {
72 showContext?: boolean;
73 moderators: Array<CommunityUser>;
74 admins: Array<UserView>;
75 // TODO is this necessary, can't I get it from the node itself?
76 postCreatorId?: number;
77 showCommunity?: boolean;
78 sort?: CommentSortType;
80 enableDownvotes: boolean;
83 export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
84 private emptyState: CommentNodeState = {
87 showRemoveDialog: false,
92 banType: BanType.Community,
96 showConfirmTransferSite: false,
97 showConfirmTransferCommunity: false,
98 showConfirmAppointAsMod: false,
99 showConfirmAppointAsAdmin: false,
100 my_vote: this.props.node.comment.my_vote,
101 score: this.props.node.comment.score,
102 upvotes: this.props.node.comment.upvotes,
103 downvotes: this.props.node.comment.downvotes,
104 borderColor: this.props.node.comment.depth
105 ? colorList[this.props.node.comment.depth % colorList.length]
111 constructor(props: any, context: any) {
112 super(props, context);
114 this.state = this.emptyState;
115 this.handleReplyCancel = this.handleReplyCancel.bind(this);
116 this.handleCommentUpvote = this.handleCommentUpvote.bind(this);
117 this.handleCommentDownvote = this.handleCommentDownvote.bind(this);
120 componentWillReceiveProps(nextProps: CommentNodeProps) {
121 this.state.my_vote = nextProps.node.comment.my_vote;
122 this.state.upvotes = nextProps.node.comment.upvotes;
123 this.state.downvotes = nextProps.node.comment.downvotes;
124 this.state.score = nextProps.node.comment.score;
125 this.state.readLoading = false;
126 this.state.saveLoading = false;
127 this.setState(this.state);
131 let node = this.props.node;
134 className={`comment ${
135 node.comment.parent_id && !this.props.noIndent ? 'ml-1' : ''
139 id={`comment-${node.comment.id}`}
140 className={`details comment-node py-2 ${
141 !this.props.noBorder ? 'border-top border-light' : ''
142 } ${this.isCommentNew ? 'mark' : ''}`}
144 !this.props.noIndent &&
145 this.props.node.comment.parent_id &&
146 `border-left: 2px ${this.state.borderColor} solid !important`
151 !this.props.noIndent &&
152 this.props.node.comment.parent_id &&
156 <div class="d-flex flex-wrap align-items-center text-muted small">
160 name: node.comment.creator_name,
161 avatar: node.comment.creator_avatar,
162 id: node.comment.creator_id,
163 local: node.comment.creator_local,
164 actor_id: node.comment.creator_actor_id,
165 published: node.comment.creator_published,
171 <div className="badge badge-light d-none d-sm-inline mr-2">
176 <div className="badge badge-light d-none d-sm-inline mr-2">
180 {this.isPostCreator && (
181 <div className="badge badge-light d-none d-sm-inline mr-2">
185 {(node.comment.banned_from_community || node.comment.banned) && (
186 <div className="badge badge-danger mr-2">
190 {this.props.showCommunity && (
192 <span class="mx-1">{i18n.t('to')}</span>
195 name: node.comment.community_name,
196 id: node.comment.community_id,
197 local: node.comment.community_local,
198 actor_id: node.comment.community_actor_id,
201 <span class="mx-2">•</span>
202 <Link class="mr-2" to={`/post/${node.comment.post_id}`}>
203 {node.comment.post_name}
208 class="btn text-muted"
209 onClick={linkEvent(this, this.handleCommentCollapse)}
211 {this.state.collapsed ? (
212 <svg class="icon icon-inline">
213 <use xlinkHref="#icon-plus-square"></use>
216 <svg class="icon icon-inline">
217 <use xlinkHref="#icon-minus-square"></use>
221 {/* This is an expanding spacer for mobile */}
222 <div className="mr-lg-4 flex-grow-1 flex-lg-grow-0 unselectable pointer mx-2"></div>
224 className={`btn p-0 unselectable pointer ${this.scoreColor}`}
225 onClick={linkEvent(node, this.handleCommentUpvote)}
226 data-tippy-content={this.pointsTippy}
228 <svg class="icon icon-inline mr-1">
229 <use xlinkHref="#icon-zap"></use>
231 <span class="mr-1">{this.state.score}</span>
233 <span className="mr-1">•</span>
235 <MomentTime data={node.comment} />
238 {/* end of user row */}
239 {this.state.showEdit && (
243 onReplyCancel={this.handleReplyCancel}
244 disabled={this.props.locked}
248 {!this.state.showEdit && !this.state.collapsed && (
250 {this.state.viewSource ? (
251 <pre>{this.commentUnlessRemoved}</pre>
255 dangerouslySetInnerHTML={mdToHtml(
256 this.commentUnlessRemoved
260 <div class="d-flex justify-content-between justify-content-lg-start flex-wrap text-muted font-weight-bold">
261 {this.props.showContext && this.linkBtn}
262 {this.props.markable && (
264 class="btn btn-link btn-animate text-muted"
265 onClick={linkEvent(this, this.handleMarkRead)}
268 ? i18n.t('mark_as_unread')
269 : i18n.t('mark_as_read')
272 {this.state.readLoading ? (
276 class={`icon icon-inline ${
277 node.comment.read && 'text-success'
280 <use xlinkHref="#icon-check"></use>
285 {UserService.Instance.user && !this.props.viewOnly && (
288 className={`btn btn-link btn-animate ${
289 this.state.my_vote == 1 ? 'text-info' : 'text-muted'
291 onClick={linkEvent(node, this.handleCommentUpvote)}
292 data-tippy-content={i18n.t('upvote')}
294 <svg class="icon icon-inline">
295 <use xlinkHref="#icon-arrow-up"></use>
297 {this.state.upvotes !== this.state.score && (
298 <span class="ml-1">{this.state.upvotes}</span>
301 {this.props.enableDownvotes && (
303 className={`btn btn-link btn-animate ${
304 this.state.my_vote == -1
308 onClick={linkEvent(node, this.handleCommentDownvote)}
309 data-tippy-content={i18n.t('downvote')}
311 <svg class="icon icon-inline">
312 <use xlinkHref="#icon-arrow-down"></use>
314 {this.state.upvotes !== this.state.score && (
315 <span class="ml-1">{this.state.downvotes}</span>
320 class="btn btn-link btn-animate text-muted"
321 onClick={linkEvent(this, this.handleReplyClick)}
322 data-tippy-content={i18n.t('reply')}
324 <svg class="icon icon-inline">
325 <use xlinkHref="#icon-reply1"></use>
328 {!this.state.showAdvanced ? (
330 className="btn btn-link btn-animate text-muted"
331 onClick={linkEvent(this, this.handleShowAdvanced)}
332 data-tippy-content={i18n.t('more')}
334 <svg class="icon icon-inline">
335 <use xlinkHref="#icon-more-vertical"></use>
340 {!this.myComment && (
341 <button class="btn btn-link btn-animate">
344 to={`/create_private_message?recipient_id=${node.comment.creator_id}`}
345 title={i18n.t('message').toLowerCase()}
348 <use xlinkHref="#icon-mail"></use>
353 {!this.props.showContext && this.linkBtn}
355 class="btn btn-link btn-animate text-muted"
358 this.handleSaveCommentClick
366 {this.state.saveLoading ? (
370 class={`icon icon-inline ${
371 node.comment.saved && 'text-warning'
374 <use xlinkHref="#icon-star"></use>
379 className="btn btn-link btn-animate text-muted"
380 onClick={linkEvent(this, this.handleViewSource)}
381 data-tippy-content={i18n.t('view_source')}
384 class={`icon icon-inline ${
385 this.state.viewSource && 'text-success'
388 <use xlinkHref="#icon-file-text"></use>
394 class="btn btn-link btn-animate text-muted"
395 onClick={linkEvent(this, this.handleEditClick)}
396 data-tippy-content={i18n.t('edit')}
398 <svg class="icon icon-inline">
399 <use xlinkHref="#icon-edit"></use>
403 class="btn btn-link btn-animate text-muted"
406 this.handleDeleteClick
409 !node.comment.deleted
415 class={`icon icon-inline ${
416 node.comment.deleted && 'text-danger'
419 <use xlinkHref="#icon-trash"></use>
424 {/* Admins and mods can remove comments */}
425 {(this.canMod || this.canAdmin) && (
427 {!node.comment.removed ? (
429 class="btn btn-link btn-animate text-muted"
432 this.handleModRemoveShow
439 class="btn btn-link btn-animate text-muted"
442 this.handleModRemoveSubmit
450 {/* Mods can ban from community, and appoint as mods to community */}
454 (!node.comment.banned_from_community ? (
456 class="btn btn-link btn-animate text-muted"
459 this.handleModBanFromCommunityShow
466 class="btn btn-link btn-animate text-muted"
469 this.handleModBanFromCommunitySubmit
475 {!node.comment.banned_from_community &&
476 (!this.state.showConfirmAppointAsMod ? (
478 class="btn btn-link btn-animate text-muted"
481 this.handleShowConfirmAppointAsMod
485 ? i18n.t('remove_as_mod')
486 : i18n.t('appoint_as_mod')}
490 <button class="btn btn-link btn-animate text-muted">
491 {i18n.t('are_you_sure')}
494 class="btn btn-link btn-animate text-muted"
497 this.handleAddModToCommunity
503 class="btn btn-link btn-animate text-muted"
506 this.handleCancelConfirmAppointAsMod
515 {/* Community creators and admins can transfer community to another mod */}
516 {(this.amCommunityCreator || this.canAdmin) &&
518 (!this.state.showConfirmTransferCommunity ? (
520 class="btn btn-link btn-animate text-muted"
523 this.handleShowConfirmTransferCommunity
526 {i18n.t('transfer_community')}
530 <button class="btn btn-link btn-animate text-muted">
531 {i18n.t('are_you_sure')}
534 class="btn btn-link btn-animate text-muted"
537 this.handleTransferCommunity
543 class="btn btn-link btn-animate text-muted"
547 .handleCancelShowConfirmTransferCommunity
554 {/* Admins can ban from all, and appoint other admins */}
558 (!node.comment.banned ? (
560 class="btn btn-link btn-animate text-muted"
563 this.handleModBanShow
566 {i18n.t('ban_from_site')}
570 class="btn btn-link btn-animate text-muted"
573 this.handleModBanSubmit
576 {i18n.t('unban_from_site')}
579 {!node.comment.banned &&
580 (!this.state.showConfirmAppointAsAdmin ? (
582 class="btn btn-link btn-animate text-muted"
585 this.handleShowConfirmAppointAsAdmin
589 ? i18n.t('remove_as_admin')
590 : i18n.t('appoint_as_admin')}
594 <button class="btn btn-link btn-animate text-muted">
595 {i18n.t('are_you_sure')}
598 class="btn btn-link btn-animate text-muted"
607 class="btn btn-link btn-animate text-muted"
610 this.handleCancelConfirmAppointAsAdmin
619 {/* Site Creator can transfer to another admin */}
620 {this.amSiteCreator &&
622 (!this.state.showConfirmTransferSite ? (
624 class="btn btn-link btn-animate text-muted"
627 this.handleShowConfirmTransferSite
630 {i18n.t('transfer_site')}
634 <button class="btn btn-link btn-animate text-muted">
635 {i18n.t('are_you_sure')}
638 class="btn btn-link btn-animate text-muted"
641 this.handleTransferSite
647 class="btn btn-link btn-animate text-muted"
650 this.handleCancelShowConfirmTransferSite
662 {/* end of button group */}
667 {/* end of details */}
668 {this.state.showRemoveDialog && (
671 onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
675 class="form-control mr-2"
676 placeholder={i18n.t('reason')}
677 value={this.state.removeReason}
678 onInput={linkEvent(this, this.handleModRemoveReasonChange)}
680 <button type="submit" class="btn btn-secondary">
681 {i18n.t('remove_comment')}
685 {this.state.showBanDialog && (
686 <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
687 <div class="form-group row">
688 <label class="col-form-label">{i18n.t('reason')}</label>
691 class="form-control mr-2"
692 placeholder={i18n.t('reason')}
693 value={this.state.banReason}
694 onInput={linkEvent(this, this.handleModBanReasonChange)}
697 {/* TODO hold off on expires until later */}
698 {/* <div class="form-group row"> */}
699 {/* <label class="col-form-label">Expires</label> */}
700 {/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
702 <div class="form-group row">
703 <button type="submit" class="btn btn-secondary">
704 {i18n.t('ban')} {node.comment.creator_name}
709 {this.state.showReply && (
712 onReplyCancel={this.handleReplyCancel}
713 disabled={this.props.locked}
717 {node.children && !this.state.collapsed && (
719 nodes={node.children}
720 locked={this.props.locked}
721 moderators={this.props.moderators}
722 admins={this.props.admins}
723 postCreatorId={this.props.postCreatorId}
724 sort={this.props.sort}
725 sortType={this.props.sortType}
726 enableDownvotes={this.props.enableDownvotes}
729 {/* A collapsed clearfix */}
730 {this.state.collapsed && <div class="row col-12"></div>}
736 let node = this.props.node;
739 class="btn btn-link btn-animate text-muted"
740 to={`/post/${node.comment.post_id}/comment/${node.comment.id}`}
741 title={this.props.showContext ? i18n.t('show_context') : i18n.t('link')}
743 <svg class="icon icon-inline">
744 <use xlinkHref="#icon-link"></use>
752 <svg class="icon icon-spinner spin">
753 <use xlinkHref="#icon-spinner"></use>
758 get myComment(): boolean {
760 UserService.Instance.user &&
761 this.props.node.comment.creator_id == UserService.Instance.user.id
765 get isMod(): boolean {
767 this.props.moderators &&
769 this.props.moderators.map(m => m.user_id),
770 this.props.node.comment.creator_id
775 get isAdmin(): boolean {
779 this.props.admins.map(a => a.id),
780 this.props.node.comment.creator_id
785 get isPostCreator(): boolean {
786 return this.props.node.comment.creator_id == this.props.postCreatorId;
789 get canMod(): boolean {
790 if (this.props.admins && this.props.moderators) {
791 let adminsThenMods = this.props.admins
793 .concat(this.props.moderators.map(m => m.user_id));
796 UserService.Instance.user,
798 this.props.node.comment.creator_id
805 get canAdmin(): boolean {
809 UserService.Instance.user,
810 this.props.admins.map(a => a.id),
811 this.props.node.comment.creator_id
816 get amCommunityCreator(): boolean {
818 this.props.moderators &&
819 UserService.Instance.user &&
820 this.props.node.comment.creator_id != UserService.Instance.user.id &&
821 UserService.Instance.user.id == this.props.moderators[0].user_id
825 get amSiteCreator(): boolean {
828 UserService.Instance.user &&
829 this.props.node.comment.creator_id != UserService.Instance.user.id &&
830 UserService.Instance.user.id == this.props.admins[0].id
834 get commentUnlessRemoved(): string {
835 let node = this.props.node;
836 return node.comment.removed
837 ? `*${i18n.t('removed')}*`
838 : node.comment.deleted
839 ? `*${i18n.t('deleted')}*`
840 : node.comment.content;
843 handleReplyClick(i: CommentNode) {
844 i.state.showReply = true;
848 handleEditClick(i: CommentNode) {
849 i.state.showEdit = true;
853 handleDeleteClick(i: CommentNode) {
854 let deleteForm: DeleteCommentForm = {
855 edit_id: i.props.node.comment.id,
856 deleted: !i.props.node.comment.deleted,
859 WebSocketService.Instance.deleteComment(deleteForm);
862 handleSaveCommentClick(i: CommentNode) {
864 i.props.node.comment.saved == undefined
866 : !i.props.node.comment.saved;
867 let form: SaveCommentForm = {
868 comment_id: i.props.node.comment.id,
872 WebSocketService.Instance.saveComment(form);
874 i.state.saveLoading = true;
875 i.setState(this.state);
878 handleReplyCancel() {
879 this.state.showReply = false;
880 this.state.showEdit = false;
881 this.setState(this.state);
884 handleCommentUpvote(i: CommentNodeI) {
885 let new_vote = this.state.my_vote == 1 ? 0 : 1;
887 if (this.state.my_vote == 1) {
889 this.state.upvotes--;
890 } else if (this.state.my_vote == -1) {
891 this.state.downvotes--;
892 this.state.upvotes++;
893 this.state.score += 2;
895 this.state.upvotes++;
899 this.state.my_vote = new_vote;
901 let form: CommentLikeForm = {
902 comment_id: i.comment.id,
903 score: this.state.my_vote,
906 WebSocketService.Instance.likeComment(form);
907 this.setState(this.state);
911 handleCommentDownvote(i: CommentNodeI) {
912 let new_vote = this.state.my_vote == -1 ? 0 : -1;
914 if (this.state.my_vote == 1) {
915 this.state.score -= 2;
916 this.state.upvotes--;
917 this.state.downvotes++;
918 } else if (this.state.my_vote == -1) {
919 this.state.downvotes--;
922 this.state.downvotes++;
926 this.state.my_vote = new_vote;
928 let form: CommentLikeForm = {
929 comment_id: i.comment.id,
930 score: this.state.my_vote,
933 WebSocketService.Instance.likeComment(form);
934 this.setState(this.state);
938 handleModRemoveShow(i: CommentNode) {
939 i.state.showRemoveDialog = true;
943 handleModRemoveReasonChange(i: CommentNode, event: any) {
944 i.state.removeReason = event.target.value;
948 handleModRemoveSubmit(i: CommentNode) {
949 event.preventDefault();
950 let form: RemoveCommentForm = {
951 edit_id: i.props.node.comment.id,
952 removed: !i.props.node.comment.removed,
953 reason: i.state.removeReason,
956 WebSocketService.Instance.removeComment(form);
958 i.state.showRemoveDialog = false;
962 handleMarkRead(i: CommentNode) {
963 // if it has a user_mention_id field, then its a mention
964 if (i.props.node.comment.user_mention_id) {
965 let form: MarkUserMentionAsReadForm = {
966 user_mention_id: i.props.node.comment.user_mention_id,
967 read: !i.props.node.comment.read,
969 WebSocketService.Instance.markUserMentionAsRead(form);
971 let form: MarkCommentAsReadForm = {
972 edit_id: i.props.node.comment.id,
973 read: !i.props.node.comment.read,
976 WebSocketService.Instance.markCommentAsRead(form);
979 i.state.readLoading = true;
980 i.setState(this.state);
983 handleModBanFromCommunityShow(i: CommentNode) {
984 i.state.showBanDialog = !i.state.showBanDialog;
985 i.state.banType = BanType.Community;
989 handleModBanShow(i: CommentNode) {
990 i.state.showBanDialog = !i.state.showBanDialog;
991 i.state.banType = BanType.Site;
995 handleModBanReasonChange(i: CommentNode, event: any) {
996 i.state.banReason = event.target.value;
1000 handleModBanExpiresChange(i: CommentNode, event: any) {
1001 i.state.banExpires = event.target.value;
1002 i.setState(i.state);
1005 handleModBanFromCommunitySubmit(i: CommentNode) {
1006 i.state.banType = BanType.Community;
1007 i.setState(i.state);
1008 i.handleModBanBothSubmit(i);
1011 handleModBanSubmit(i: CommentNode) {
1012 i.state.banType = BanType.Site;
1013 i.setState(i.state);
1014 i.handleModBanBothSubmit(i);
1017 handleModBanBothSubmit(i: CommentNode) {
1018 event.preventDefault();
1020 if (i.state.banType == BanType.Community) {
1021 let form: BanFromCommunityForm = {
1022 user_id: i.props.node.comment.creator_id,
1023 community_id: i.props.node.comment.community_id,
1024 ban: !i.props.node.comment.banned_from_community,
1025 reason: i.state.banReason,
1026 expires: getUnixTime(i.state.banExpires),
1028 WebSocketService.Instance.banFromCommunity(form);
1030 let form: BanUserForm = {
1031 user_id: i.props.node.comment.creator_id,
1032 ban: !i.props.node.comment.banned,
1033 reason: i.state.banReason,
1034 expires: getUnixTime(i.state.banExpires),
1036 WebSocketService.Instance.banUser(form);
1039 i.state.showBanDialog = false;
1040 i.setState(i.state);
1043 handleShowConfirmAppointAsMod(i: CommentNode) {
1044 i.state.showConfirmAppointAsMod = true;
1045 i.setState(i.state);
1048 handleCancelConfirmAppointAsMod(i: CommentNode) {
1049 i.state.showConfirmAppointAsMod = false;
1050 i.setState(i.state);
1053 handleAddModToCommunity(i: CommentNode) {
1054 let form: AddModToCommunityForm = {
1055 user_id: i.props.node.comment.creator_id,
1056 community_id: i.props.node.comment.community_id,
1059 WebSocketService.Instance.addModToCommunity(form);
1060 i.state.showConfirmAppointAsMod = false;
1061 i.setState(i.state);
1064 handleShowConfirmAppointAsAdmin(i: CommentNode) {
1065 i.state.showConfirmAppointAsAdmin = true;
1066 i.setState(i.state);
1069 handleCancelConfirmAppointAsAdmin(i: CommentNode) {
1070 i.state.showConfirmAppointAsAdmin = false;
1071 i.setState(i.state);
1074 handleAddAdmin(i: CommentNode) {
1075 let form: AddAdminForm = {
1076 user_id: i.props.node.comment.creator_id,
1079 WebSocketService.Instance.addAdmin(form);
1080 i.state.showConfirmAppointAsAdmin = false;
1081 i.setState(i.state);
1084 handleShowConfirmTransferCommunity(i: CommentNode) {
1085 i.state.showConfirmTransferCommunity = true;
1086 i.setState(i.state);
1089 handleCancelShowConfirmTransferCommunity(i: CommentNode) {
1090 i.state.showConfirmTransferCommunity = false;
1091 i.setState(i.state);
1094 handleTransferCommunity(i: CommentNode) {
1095 let form: TransferCommunityForm = {
1096 community_id: i.props.node.comment.community_id,
1097 user_id: i.props.node.comment.creator_id,
1099 WebSocketService.Instance.transferCommunity(form);
1100 i.state.showConfirmTransferCommunity = false;
1101 i.setState(i.state);
1104 handleShowConfirmTransferSite(i: CommentNode) {
1105 i.state.showConfirmTransferSite = true;
1106 i.setState(i.state);
1109 handleCancelShowConfirmTransferSite(i: CommentNode) {
1110 i.state.showConfirmTransferSite = false;
1111 i.setState(i.state);
1114 handleTransferSite(i: CommentNode) {
1115 let form: TransferSiteForm = {
1116 user_id: i.props.node.comment.creator_id,
1118 WebSocketService.Instance.transferSite(form);
1119 i.state.showConfirmTransferSite = false;
1120 i.setState(i.state);
1123 get isCommentNew(): boolean {
1124 let now = moment.utc().subtract(10, 'minutes');
1125 let then = moment.utc(this.props.node.comment.published);
1126 return now.isBefore(then);
1129 handleCommentCollapse(i: CommentNode) {
1130 i.state.collapsed = !i.state.collapsed;
1131 i.setState(i.state);
1134 handleViewSource(i: CommentNode) {
1135 i.state.viewSource = !i.state.viewSource;
1136 i.setState(i.state);
1139 handleShowAdvanced(i: CommentNode) {
1140 i.state.showAdvanced = !i.state.showAdvanced;
1141 i.setState(i.state);
1146 if (this.state.my_vote == 1) {
1148 } else if (this.state.my_vote == -1) {
1149 return 'text-danger';
1151 return 'text-muted';
1155 get pointsTippy(): string {
1156 let points = i18n.t('number_of_points', {
1157 count: this.state.score,
1160 let upvotes = i18n.t('number_of_upvotes', {
1161 count: this.state.upvotes,
1164 let downvotes = i18n.t('number_of_downvotes', {
1165 count: this.state.downvotes,
1168 return `${points} • ${upvotes} • ${downvotes}`;