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`
145 class={`${!this.props.noIndent &&
146 this.props.node.comment.parent_id &&
149 <div class="d-flex flex-wrap align-items-center mb-1 mt-1 text-muted small">
153 name: node.comment.creator_name,
154 avatar: node.comment.creator_avatar,
159 <div className="badge badge-light d-none d-sm-inline mr-2">
164 <div className="badge badge-light d-none d-sm-inline mr-2">
168 {this.isPostCreator && (
169 <div className="badge badge-light d-none d-sm-inline mr-2">
173 {(node.comment.banned_from_community || node.comment.banned) && (
174 <div className="badge badge-danger mr-2">
178 {this.props.showCommunity && (
180 <span class="mx-1">{i18n.t('to')}</span>
181 <Link class="mr-2" to={`/c/${node.comment.community_name}`}>
182 {node.comment.community_name}
187 className="mr-lg-4 flex-grow-1 flex-lg-grow-0 unselectable pointer mx-2"
188 onClick={linkEvent(this, this.handleCommentCollapse)}
190 {this.state.collapsed ? (
191 <svg class="icon icon-inline">
192 <use xlinkHref="#icon-plus-square"></use>
195 <svg class="icon icon-inline">
196 <use xlinkHref="#icon-minus-square"></use>
201 className={`unselectable pointer ${this.scoreColor}`}
202 onClick={linkEvent(node, this.handleCommentUpvote)}
203 data-tippy-content={this.pointsTippy}
205 <svg class="icon icon-inline mr-1">
206 <use xlinkHref="#icon-zap"></use>
208 <span class="mr-1">{this.state.score}</span>
210 <span className="mr-1">•</span>
212 <MomentTime data={node.comment} />
215 {/* end of user row */}
216 {this.state.showEdit && (
220 onReplyCancel={this.handleReplyCancel}
221 disabled={this.props.locked}
224 {!this.state.showEdit && !this.state.collapsed && (
226 {this.state.viewSource ? (
227 <pre>{this.commentUnlessRemoved}</pre>
231 dangerouslySetInnerHTML={mdToHtml(
232 this.commentUnlessRemoved
236 <div class="d-flex justify-content-between justify-content-lg-start flex-wrap text-muted font-weight-bold">
237 {this.props.showContext && this.linkBtn}
238 {this.props.markable && (
240 class="btn btn-link btn-animate text-muted"
241 onClick={linkEvent(this, this.handleMarkRead)}
244 ? i18n.t('mark_as_unread')
245 : i18n.t('mark_as_read')
248 {this.state.readLoading ? (
252 class={`icon icon-inline ${node.comment.read &&
255 <use xlinkHref="#icon-check"></use>
260 {UserService.Instance.user && !this.props.viewOnly && (
263 className={`btn btn-link btn-animate ${
264 this.state.my_vote == 1 ? 'text-info' : 'text-muted'
266 onClick={linkEvent(node, this.handleCommentUpvote)}
267 data-tippy-content={i18n.t('upvote')}
269 <svg class="icon icon-inline">
270 <use xlinkHref="#icon-arrow-up"></use>
272 {this.state.upvotes !== this.state.score && (
273 <span class="ml-1">{this.state.upvotes}</span>
276 {WebSocketService.Instance.site.enable_downvotes && (
278 className={`btn btn-link btn-animate ${
279 this.state.my_vote == -1
283 onClick={linkEvent(node, this.handleCommentDownvote)}
284 data-tippy-content={i18n.t('downvote')}
286 <svg class="icon icon-inline">
287 <use xlinkHref="#icon-arrow-down"></use>
289 {this.state.upvotes !== this.state.score && (
290 <span class="ml-1">{this.state.downvotes}</span>
295 class="btn btn-link btn-animate text-muted"
296 onClick={linkEvent(this, this.handleSaveCommentClick)}
298 node.comment.saved ? i18n.t('unsave') : i18n.t('save')
301 {this.state.saveLoading ? (
305 class={`icon icon-inline ${node.comment.saved &&
308 <use xlinkHref="#icon-star"></use>
313 class="btn btn-link btn-animate text-muted"
314 onClick={linkEvent(this, this.handleReplyClick)}
315 data-tippy-content={i18n.t('reply')}
317 <svg class="icon icon-inline">
318 <use xlinkHref="#icon-reply1"></use>
321 {!this.state.showAdvanced ? (
323 className="btn btn-link btn-animate text-muted"
324 onClick={linkEvent(this, this.handleShowAdvanced)}
325 data-tippy-content={i18n.t('more')}
327 <svg class="icon icon-inline">
328 <use xlinkHref="#icon-more-vertical"></use>
333 {!this.myComment && (
334 <button class="btn btn-link btn-animate">
337 to={`/create_private_message?recipient_id=${node.comment.creator_id}`}
338 title={i18n.t('message').toLowerCase()}
341 <use xlinkHref="#icon-mail"></use>
346 {!this.props.showContext && this.linkBtn}
348 className="btn btn-link btn-animate text-muted"
349 onClick={linkEvent(this, this.handleViewSource)}
350 data-tippy-content={i18n.t('view_source')}
353 class={`icon icon-inline ${this.state
354 .viewSource && 'text-success'}`}
356 <use xlinkHref="#icon-file-text"></use>
362 class="btn btn-link btn-animate text-muted"
363 onClick={linkEvent(this, this.handleEditClick)}
364 data-tippy-content={i18n.t('edit')}
366 <svg class="icon icon-inline">
367 <use xlinkHref="#icon-edit"></use>
371 class="btn btn-link btn-animate text-muted"
374 this.handleDeleteClick
377 !node.comment.deleted
383 class={`icon icon-inline ${node.comment
384 .deleted && 'text-danger'}`}
386 <use xlinkHref="#icon-trash"></use>
391 {/* Admins and mods can remove comments */}
392 {(this.canMod || this.canAdmin) && (
394 {!node.comment.removed ? (
396 class="btn btn-link btn-animate text-muted"
399 this.handleModRemoveShow
406 class="btn btn-link btn-animate text-muted"
409 this.handleModRemoveSubmit
417 {/* Mods can ban from community, and appoint as mods to community */}
421 (!node.comment.banned_from_community ? (
423 class="btn btn-link btn-animate text-muted"
426 this.handleModBanFromCommunityShow
433 class="btn btn-link btn-animate text-muted"
436 this.handleModBanFromCommunitySubmit
442 {!node.comment.banned_from_community &&
443 (!this.state.showConfirmAppointAsMod ? (
445 class="btn btn-link btn-animate text-muted"
448 this.handleShowConfirmAppointAsMod
452 ? i18n.t('remove_as_mod')
453 : i18n.t('appoint_as_mod')}
457 <button class="btn btn-link btn-animate text-muted">
458 {i18n.t('are_you_sure')}
461 class="btn btn-link btn-animate text-muted"
464 this.handleAddModToCommunity
470 class="btn btn-link btn-animate text-muted"
473 this.handleCancelConfirmAppointAsMod
482 {/* Community creators and admins can transfer community to another mod */}
483 {(this.amCommunityCreator || this.canAdmin) &&
485 (!this.state.showConfirmTransferCommunity ? (
487 class="btn btn-link btn-animate text-muted"
490 this.handleShowConfirmTransferCommunity
493 {i18n.t('transfer_community')}
497 <button class="btn btn-link btn-animate text-muted">
498 {i18n.t('are_you_sure')}
501 class="btn btn-link btn-animate text-muted"
504 this.handleTransferCommunity
510 class="btn btn-link btn-animate text-muted"
514 .handleCancelShowConfirmTransferCommunity
521 {/* Admins can ban from all, and appoint other admins */}
525 (!node.comment.banned ? (
527 class="btn btn-link btn-animate text-muted"
530 this.handleModBanShow
533 {i18n.t('ban_from_site')}
537 class="btn btn-link btn-animate text-muted"
540 this.handleModBanSubmit
543 {i18n.t('unban_from_site')}
546 {!node.comment.banned &&
547 (!this.state.showConfirmAppointAsAdmin ? (
549 class="btn btn-link btn-animate text-muted"
552 this.handleShowConfirmAppointAsAdmin
556 ? i18n.t('remove_as_admin')
557 : i18n.t('appoint_as_admin')}
561 <button class="btn btn-link btn-animate text-muted">
562 {i18n.t('are_you_sure')}
565 class="btn btn-link btn-animate text-muted"
574 class="btn btn-link btn-animate text-muted"
577 this.handleCancelConfirmAppointAsAdmin
586 {/* Site Creator can transfer to another admin */}
587 {this.amSiteCreator &&
589 (!this.state.showConfirmTransferSite ? (
591 class="btn btn-link btn-animate text-muted"
594 this.handleShowConfirmTransferSite
597 {i18n.t('transfer_site')}
601 <button class="btn btn-link btn-animate text-muted">
602 {i18n.t('are_you_sure')}
605 class="btn btn-link btn-animate text-muted"
608 this.handleTransferSite
614 class="btn btn-link btn-animate text-muted"
617 this.handleCancelShowConfirmTransferSite
629 {/* end of button group */}
634 {/* end of details */}
635 {this.state.showRemoveDialog && (
638 onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
642 class="form-control mr-2"
643 placeholder={i18n.t('reason')}
644 value={this.state.removeReason}
645 onInput={linkEvent(this, this.handleModRemoveReasonChange)}
647 <button type="submit" class="btn btn-secondary">
648 {i18n.t('remove_comment')}
652 {this.state.showBanDialog && (
653 <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
654 <div class="form-group row">
655 <label class="col-form-label">{i18n.t('reason')}</label>
658 class="form-control mr-2"
659 placeholder={i18n.t('reason')}
660 value={this.state.banReason}
661 onInput={linkEvent(this, this.handleModBanReasonChange)}
664 {/* TODO hold off on expires until later */}
665 {/* <div class="form-group row"> */}
666 {/* <label class="col-form-label">Expires</label> */}
667 {/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
669 <div class="form-group row">
670 <button type="submit" class="btn btn-secondary">
671 {i18n.t('ban')} {node.comment.creator_name}
676 {this.state.showReply && (
679 onReplyCancel={this.handleReplyCancel}
680 disabled={this.props.locked}
683 {node.children && !this.state.collapsed && (
685 nodes={node.children}
686 locked={this.props.locked}
687 moderators={this.props.moderators}
688 admins={this.props.admins}
689 postCreatorId={this.props.postCreatorId}
690 sort={this.props.sort}
691 sortType={this.props.sortType}
694 {/* A collapsed clearfix */}
695 {this.state.collapsed && <div class="row col-12"></div>}
701 let node = this.props.node;
703 <button className="btn btn-link btn-animate">
706 to={`/post/${node.comment.post_id}/comment/${node.comment.id}`}
708 this.props.showContext ? i18n.t('show_context') : i18n.t('link')
711 <svg class="icon icon-inline">
712 <use xlinkHref="#icon-link"></use>
721 <svg class="icon icon-spinner spin">
722 <use xlinkHref="#icon-spinner"></use>
727 get myComment(): boolean {
729 UserService.Instance.user &&
730 this.props.node.comment.creator_id == UserService.Instance.user.id
734 get isMod(): boolean {
736 this.props.moderators &&
738 this.props.moderators.map(m => m.user_id),
739 this.props.node.comment.creator_id
744 get isAdmin(): boolean {
748 this.props.admins.map(a => a.id),
749 this.props.node.comment.creator_id
754 get isPostCreator(): boolean {
755 return this.props.node.comment.creator_id == this.props.postCreatorId;
758 get canMod(): boolean {
759 if (this.props.admins && this.props.moderators) {
760 let adminsThenMods = this.props.admins
762 .concat(this.props.moderators.map(m => m.user_id));
765 UserService.Instance.user,
767 this.props.node.comment.creator_id
774 get canAdmin(): boolean {
778 UserService.Instance.user,
779 this.props.admins.map(a => a.id),
780 this.props.node.comment.creator_id
785 get amCommunityCreator(): boolean {
787 this.props.moderators &&
788 UserService.Instance.user &&
789 this.props.node.comment.creator_id != UserService.Instance.user.id &&
790 UserService.Instance.user.id == this.props.moderators[0].user_id
794 get amSiteCreator(): boolean {
797 UserService.Instance.user &&
798 this.props.node.comment.creator_id != UserService.Instance.user.id &&
799 UserService.Instance.user.id == this.props.admins[0].id
803 get commentUnlessRemoved(): string {
804 let node = this.props.node;
805 return node.comment.removed
806 ? `*${i18n.t('removed')}*`
807 : node.comment.deleted
808 ? `*${i18n.t('deleted')}*`
809 : node.comment.content;
812 handleReplyClick(i: CommentNode) {
813 i.state.showReply = true;
817 handleEditClick(i: CommentNode) {
818 i.state.showEdit = true;
822 handleDeleteClick(i: CommentNode) {
823 let deleteForm: CommentFormI = {
824 content: i.props.node.comment.content,
825 edit_id: i.props.node.comment.id,
826 creator_id: i.props.node.comment.creator_id,
827 post_id: i.props.node.comment.post_id,
828 parent_id: i.props.node.comment.parent_id,
829 deleted: !i.props.node.comment.deleted,
832 WebSocketService.Instance.editComment(deleteForm);
835 handleSaveCommentClick(i: CommentNode) {
837 i.props.node.comment.saved == undefined
839 : !i.props.node.comment.saved;
840 let form: SaveCommentForm = {
841 comment_id: i.props.node.comment.id,
845 WebSocketService.Instance.saveComment(form);
847 i.state.saveLoading = true;
848 i.setState(this.state);
851 handleReplyCancel() {
852 this.state.showReply = false;
853 this.state.showEdit = false;
854 this.setState(this.state);
857 handleCommentUpvote(i: CommentNodeI) {
858 let new_vote = this.state.my_vote == 1 ? 0 : 1;
860 if (this.state.my_vote == 1) {
862 this.state.upvotes--;
863 } else if (this.state.my_vote == -1) {
864 this.state.downvotes--;
865 this.state.upvotes++;
866 this.state.score += 2;
868 this.state.upvotes++;
872 this.state.my_vote = new_vote;
874 let form: CommentLikeForm = {
875 comment_id: i.comment.id,
876 post_id: i.comment.post_id,
877 score: this.state.my_vote,
880 WebSocketService.Instance.likeComment(form);
881 this.setState(this.state);
885 handleCommentDownvote(i: CommentNodeI) {
886 let new_vote = this.state.my_vote == -1 ? 0 : -1;
888 if (this.state.my_vote == 1) {
889 this.state.score -= 2;
890 this.state.upvotes--;
891 this.state.downvotes++;
892 } else if (this.state.my_vote == -1) {
893 this.state.downvotes--;
896 this.state.downvotes++;
900 this.state.my_vote = new_vote;
902 let form: CommentLikeForm = {
903 comment_id: i.comment.id,
904 post_id: i.comment.post_id,
905 score: this.state.my_vote,
908 WebSocketService.Instance.likeComment(form);
909 this.setState(this.state);
913 handleModRemoveShow(i: CommentNode) {
914 i.state.showRemoveDialog = true;
918 handleModRemoveReasonChange(i: CommentNode, event: any) {
919 i.state.removeReason = event.target.value;
923 handleModRemoveSubmit(i: CommentNode) {
924 event.preventDefault();
925 let form: CommentFormI = {
926 content: i.props.node.comment.content,
927 edit_id: i.props.node.comment.id,
928 creator_id: i.props.node.comment.creator_id,
929 post_id: i.props.node.comment.post_id,
930 parent_id: i.props.node.comment.parent_id,
931 removed: !i.props.node.comment.removed,
932 reason: i.state.removeReason,
935 WebSocketService.Instance.editComment(form);
937 i.state.showRemoveDialog = false;
941 handleMarkRead(i: CommentNode) {
942 // if it has a user_mention_id field, then its a mention
943 if (i.props.node.comment.user_mention_id) {
944 let form: EditUserMentionForm = {
945 user_mention_id: i.props.node.comment.user_mention_id,
946 read: !i.props.node.comment.read,
948 WebSocketService.Instance.editUserMention(form);
950 let form: CommentFormI = {
951 content: i.props.node.comment.content,
952 edit_id: i.props.node.comment.id,
953 creator_id: i.props.node.comment.creator_id,
954 post_id: i.props.node.comment.post_id,
955 parent_id: i.props.node.comment.parent_id,
956 read: !i.props.node.comment.read,
959 WebSocketService.Instance.editComment(form);
962 i.state.readLoading = true;
963 i.setState(this.state);
966 handleModBanFromCommunityShow(i: CommentNode) {
967 i.state.showBanDialog = !i.state.showBanDialog;
968 i.state.banType = BanType.Community;
972 handleModBanShow(i: CommentNode) {
973 i.state.showBanDialog = !i.state.showBanDialog;
974 i.state.banType = BanType.Site;
978 handleModBanReasonChange(i: CommentNode, event: any) {
979 i.state.banReason = event.target.value;
983 handleModBanExpiresChange(i: CommentNode, event: any) {
984 i.state.banExpires = event.target.value;
988 handleModBanFromCommunitySubmit(i: CommentNode) {
989 i.state.banType = BanType.Community;
991 i.handleModBanBothSubmit(i);
994 handleModBanSubmit(i: CommentNode) {
995 i.state.banType = BanType.Site;
997 i.handleModBanBothSubmit(i);
1000 handleModBanBothSubmit(i: CommentNode) {
1001 event.preventDefault();
1003 if (i.state.banType == BanType.Community) {
1004 let form: BanFromCommunityForm = {
1005 user_id: i.props.node.comment.creator_id,
1006 community_id: i.props.node.comment.community_id,
1007 ban: !i.props.node.comment.banned_from_community,
1008 reason: i.state.banReason,
1009 expires: getUnixTime(i.state.banExpires),
1011 WebSocketService.Instance.banFromCommunity(form);
1013 let form: BanUserForm = {
1014 user_id: i.props.node.comment.creator_id,
1015 ban: !i.props.node.comment.banned,
1016 reason: i.state.banReason,
1017 expires: getUnixTime(i.state.banExpires),
1019 WebSocketService.Instance.banUser(form);
1022 i.state.showBanDialog = false;
1023 i.setState(i.state);
1026 handleShowConfirmAppointAsMod(i: CommentNode) {
1027 i.state.showConfirmAppointAsMod = true;
1028 i.setState(i.state);
1031 handleCancelConfirmAppointAsMod(i: CommentNode) {
1032 i.state.showConfirmAppointAsMod = false;
1033 i.setState(i.state);
1036 handleAddModToCommunity(i: CommentNode) {
1037 let form: AddModToCommunityForm = {
1038 user_id: i.props.node.comment.creator_id,
1039 community_id: i.props.node.comment.community_id,
1042 WebSocketService.Instance.addModToCommunity(form);
1043 i.state.showConfirmAppointAsMod = false;
1044 i.setState(i.state);
1047 handleShowConfirmAppointAsAdmin(i: CommentNode) {
1048 i.state.showConfirmAppointAsAdmin = true;
1049 i.setState(i.state);
1052 handleCancelConfirmAppointAsAdmin(i: CommentNode) {
1053 i.state.showConfirmAppointAsAdmin = false;
1054 i.setState(i.state);
1057 handleAddAdmin(i: CommentNode) {
1058 let form: AddAdminForm = {
1059 user_id: i.props.node.comment.creator_id,
1062 WebSocketService.Instance.addAdmin(form);
1063 i.state.showConfirmAppointAsAdmin = false;
1064 i.setState(i.state);
1067 handleShowConfirmTransferCommunity(i: CommentNode) {
1068 i.state.showConfirmTransferCommunity = true;
1069 i.setState(i.state);
1072 handleCancelShowConfirmTransferCommunity(i: CommentNode) {
1073 i.state.showConfirmTransferCommunity = false;
1074 i.setState(i.state);
1077 handleTransferCommunity(i: CommentNode) {
1078 let form: TransferCommunityForm = {
1079 community_id: i.props.node.comment.community_id,
1080 user_id: i.props.node.comment.creator_id,
1082 WebSocketService.Instance.transferCommunity(form);
1083 i.state.showConfirmTransferCommunity = false;
1084 i.setState(i.state);
1087 handleShowConfirmTransferSite(i: CommentNode) {
1088 i.state.showConfirmTransferSite = true;
1089 i.setState(i.state);
1092 handleCancelShowConfirmTransferSite(i: CommentNode) {
1093 i.state.showConfirmTransferSite = false;
1094 i.setState(i.state);
1097 handleTransferSite(i: CommentNode) {
1098 let form: TransferSiteForm = {
1099 user_id: i.props.node.comment.creator_id,
1101 WebSocketService.Instance.transferSite(form);
1102 i.state.showConfirmTransferSite = false;
1103 i.setState(i.state);
1106 get isCommentNew(): boolean {
1107 let now = moment.utc().subtract(10, 'minutes');
1108 let then = moment.utc(this.props.node.comment.published);
1109 return now.isBefore(then);
1112 handleCommentCollapse(i: CommentNode) {
1113 i.state.collapsed = !i.state.collapsed;
1114 i.setState(i.state);
1117 handleViewSource(i: CommentNode) {
1118 i.state.viewSource = !i.state.viewSource;
1119 i.setState(i.state);
1122 handleShowAdvanced(i: CommentNode) {
1123 i.state.showAdvanced = !i.state.showAdvanced;
1124 i.setState(i.state);
1129 if (this.state.my_vote == 1) {
1131 } else if (this.state.my_vote == -1) {
1132 return 'text-danger';
1134 return 'text-muted';
1138 get pointsTippy(): string {
1139 let points = i18n.t('number_of_points', {
1140 count: this.state.score,
1143 let upvotes = i18n.t('number_of_upvotes', {
1144 count: this.state.upvotes,
1147 let downvotes = i18n.t('number_of_downvotes', {
1148 count: this.state.downvotes,
1151 return `${points} • ${upvotes} • ${downvotes}`;