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 {
71 showContext?: boolean;
72 moderators: Array<CommunityUser>;
73 admins: Array<UserView>;
74 // TODO is this necessary, can't I get it from the node itself?
75 postCreatorId?: number;
76 showCommunity?: boolean;
77 sort?: CommentSortType;
79 enableDownvotes: boolean;
82 export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
83 private emptyState: CommentNodeState = {
86 showRemoveDialog: false,
91 banType: BanType.Community,
95 showConfirmTransferSite: false,
96 showConfirmTransferCommunity: false,
97 showConfirmAppointAsMod: false,
98 showConfirmAppointAsAdmin: false,
99 my_vote: this.props.node.comment.my_vote,
100 score: this.props.node.comment.score,
101 upvotes: this.props.node.comment.upvotes,
102 downvotes: this.props.node.comment.downvotes,
103 borderColor: this.props.node.comment.depth
104 ? colorList[this.props.node.comment.depth % colorList.length]
110 constructor(props: any, context: any) {
111 super(props, context);
113 this.state = this.emptyState;
114 this.handleReplyCancel = this.handleReplyCancel.bind(this);
115 this.handleCommentUpvote = this.handleCommentUpvote.bind(this);
116 this.handleCommentDownvote = this.handleCommentDownvote.bind(this);
119 componentWillReceiveProps(nextProps: CommentNodeProps) {
120 this.state.my_vote = nextProps.node.comment.my_vote;
121 this.state.upvotes = nextProps.node.comment.upvotes;
122 this.state.downvotes = nextProps.node.comment.downvotes;
123 this.state.score = nextProps.node.comment.score;
124 this.state.readLoading = false;
125 this.state.saveLoading = false;
126 this.setState(this.state);
130 let node = this.props.node;
133 className={`comment ${
134 node.comment.parent_id && !this.props.noIndent ? 'ml-1' : ''
138 id={`comment-${node.comment.id}`}
139 className={`details comment-node border-top border-light py-2 ${
140 this.isCommentNew ? 'mark' : ''
143 !this.props.noIndent &&
144 this.props.node.comment.parent_id &&
145 `border-left: 2px ${this.state.borderColor} solid !important`
150 !this.props.noIndent &&
151 this.props.node.comment.parent_id &&
155 <div class="d-flex flex-wrap align-items-center text-muted small">
159 name: node.comment.creator_name,
160 avatar: node.comment.creator_avatar,
161 id: node.comment.creator_id,
162 local: node.comment.creator_local,
163 actor_id: node.comment.creator_actor_id,
164 published: node.comment.creator_published,
170 <div className="badge badge-light d-none d-sm-inline mr-2">
175 <div className="badge badge-light d-none d-sm-inline mr-2">
179 {this.isPostCreator && (
180 <div className="badge badge-light d-none d-sm-inline mr-2">
184 {(node.comment.banned_from_community || node.comment.banned) && (
185 <div className="badge badge-danger mr-2">
189 {this.props.showCommunity && (
191 <span class="mx-1">{i18n.t('to')}</span>
194 name: node.comment.community_name,
195 id: node.comment.community_id,
196 local: node.comment.community_local,
197 actor_id: node.comment.community_actor_id,
200 <span class="mx-2">•</span>
201 <Link class="mr-2" to={`/post/${node.comment.post_id}`}>
202 {node.comment.post_name}
207 class="btn btn-sm text-muted"
208 onClick={linkEvent(this, this.handleCommentCollapse)}
210 {this.state.collapsed ? (
211 <svg class="icon icon-inline">
212 <use xlinkHref="#icon-plus-square"></use>
215 <svg class="icon icon-inline">
216 <use xlinkHref="#icon-minus-square"></use>
220 {/* This is an expanding spacer for mobile */}
221 <div className="mr-lg-4 flex-grow-1 flex-lg-grow-0 unselectable pointer mx-2"></div>
223 className={`btn btn-sm p-0 unselectable pointer ${this.scoreColor}`}
224 onClick={linkEvent(node, this.handleCommentUpvote)}
225 data-tippy-content={this.pointsTippy}
227 <svg class="icon icon-inline mr-1">
228 <use xlinkHref="#icon-zap"></use>
230 <span class="mr-1">{this.state.score}</span>
232 <span className="mr-1">•</span>
234 <MomentTime data={node.comment} />
237 {/* end of user row */}
238 {this.state.showEdit && (
242 onReplyCancel={this.handleReplyCancel}
243 disabled={this.props.locked}
247 {!this.state.showEdit && !this.state.collapsed && (
249 {this.state.viewSource ? (
250 <pre>{this.commentUnlessRemoved}</pre>
254 dangerouslySetInnerHTML={mdToHtml(
255 this.commentUnlessRemoved
259 <div class="d-flex justify-content-between justify-content-lg-start flex-wrap text-muted font-weight-bold">
260 {this.props.showContext && this.linkBtn}
261 {this.props.markable && (
263 class="btn btn-link btn-animate text-muted"
264 onClick={linkEvent(this, this.handleMarkRead)}
267 ? i18n.t('mark_as_unread')
268 : i18n.t('mark_as_read')
271 {this.state.readLoading ? (
275 class={`icon icon-inline ${
276 node.comment.read && 'text-success'
279 <use xlinkHref="#icon-check"></use>
284 {UserService.Instance.user && !this.props.viewOnly && (
287 className={`btn btn-link btn-animate ${
288 this.state.my_vote == 1 ? 'text-info' : 'text-muted'
290 onClick={linkEvent(node, this.handleCommentUpvote)}
291 data-tippy-content={i18n.t('upvote')}
293 <svg class="icon icon-inline">
294 <use xlinkHref="#icon-arrow-up"></use>
296 {this.state.upvotes !== this.state.score && (
297 <span class="ml-1">{this.state.upvotes}</span>
300 {this.props.enableDownvotes && (
302 className={`btn btn-link btn-animate ${
303 this.state.my_vote == -1
307 onClick={linkEvent(node, this.handleCommentDownvote)}
308 data-tippy-content={i18n.t('downvote')}
310 <svg class="icon icon-inline">
311 <use xlinkHref="#icon-arrow-down"></use>
313 {this.state.upvotes !== this.state.score && (
314 <span class="ml-1">{this.state.downvotes}</span>
319 class="btn btn-link btn-animate text-muted"
320 onClick={linkEvent(this, this.handleReplyClick)}
321 data-tippy-content={i18n.t('reply')}
323 <svg class="icon icon-inline">
324 <use xlinkHref="#icon-reply1"></use>
327 {!this.state.showAdvanced ? (
329 className="btn btn-link btn-animate text-muted"
330 onClick={linkEvent(this, this.handleShowAdvanced)}
331 data-tippy-content={i18n.t('more')}
333 <svg class="icon icon-inline">
334 <use xlinkHref="#icon-more-vertical"></use>
339 {!this.myComment && (
340 <button class="btn btn-link btn-animate">
343 to={`/create_private_message?recipient_id=${node.comment.creator_id}`}
344 title={i18n.t('message').toLowerCase()}
347 <use xlinkHref="#icon-mail"></use>
352 {!this.props.showContext && this.linkBtn}
354 class="btn btn-link btn-animate text-muted"
357 this.handleSaveCommentClick
365 {this.state.saveLoading ? (
369 class={`icon icon-inline ${
370 node.comment.saved && 'text-warning'
373 <use xlinkHref="#icon-star"></use>
378 className="btn btn-link btn-animate text-muted"
379 onClick={linkEvent(this, this.handleViewSource)}
380 data-tippy-content={i18n.t('view_source')}
383 class={`icon icon-inline ${
384 this.state.viewSource && 'text-success'
387 <use xlinkHref="#icon-file-text"></use>
393 class="btn btn-link btn-animate text-muted"
394 onClick={linkEvent(this, this.handleEditClick)}
395 data-tippy-content={i18n.t('edit')}
397 <svg class="icon icon-inline">
398 <use xlinkHref="#icon-edit"></use>
402 class="btn btn-link btn-animate text-muted"
405 this.handleDeleteClick
408 !node.comment.deleted
414 class={`icon icon-inline ${
415 node.comment.deleted && 'text-danger'
418 <use xlinkHref="#icon-trash"></use>
423 {/* Admins and mods can remove comments */}
424 {(this.canMod || this.canAdmin) && (
426 {!node.comment.removed ? (
428 class="btn btn-link btn-animate text-muted"
431 this.handleModRemoveShow
438 class="btn btn-link btn-animate text-muted"
441 this.handleModRemoveSubmit
449 {/* Mods can ban from community, and appoint as mods to community */}
453 (!node.comment.banned_from_community ? (
455 class="btn btn-link btn-animate text-muted"
458 this.handleModBanFromCommunityShow
465 class="btn btn-link btn-animate text-muted"
468 this.handleModBanFromCommunitySubmit
474 {!node.comment.banned_from_community &&
475 (!this.state.showConfirmAppointAsMod ? (
477 class="btn btn-link btn-animate text-muted"
480 this.handleShowConfirmAppointAsMod
484 ? i18n.t('remove_as_mod')
485 : i18n.t('appoint_as_mod')}
489 <button class="btn btn-link btn-animate text-muted">
490 {i18n.t('are_you_sure')}
493 class="btn btn-link btn-animate text-muted"
496 this.handleAddModToCommunity
502 class="btn btn-link btn-animate text-muted"
505 this.handleCancelConfirmAppointAsMod
514 {/* Community creators and admins can transfer community to another mod */}
515 {(this.amCommunityCreator || this.canAdmin) &&
517 (!this.state.showConfirmTransferCommunity ? (
519 class="btn btn-link btn-animate text-muted"
522 this.handleShowConfirmTransferCommunity
525 {i18n.t('transfer_community')}
529 <button class="btn btn-link btn-animate text-muted">
530 {i18n.t('are_you_sure')}
533 class="btn btn-link btn-animate text-muted"
536 this.handleTransferCommunity
542 class="btn btn-link btn-animate text-muted"
546 .handleCancelShowConfirmTransferCommunity
553 {/* Admins can ban from all, and appoint other admins */}
557 (!node.comment.banned ? (
559 class="btn btn-link btn-animate text-muted"
562 this.handleModBanShow
565 {i18n.t('ban_from_site')}
569 class="btn btn-link btn-animate text-muted"
572 this.handleModBanSubmit
575 {i18n.t('unban_from_site')}
578 {!node.comment.banned &&
579 (!this.state.showConfirmAppointAsAdmin ? (
581 class="btn btn-link btn-animate text-muted"
584 this.handleShowConfirmAppointAsAdmin
588 ? i18n.t('remove_as_admin')
589 : i18n.t('appoint_as_admin')}
593 <button class="btn btn-link btn-animate text-muted">
594 {i18n.t('are_you_sure')}
597 class="btn btn-link btn-animate text-muted"
606 class="btn btn-link btn-animate text-muted"
609 this.handleCancelConfirmAppointAsAdmin
618 {/* Site Creator can transfer to another admin */}
619 {this.amSiteCreator &&
621 (!this.state.showConfirmTransferSite ? (
623 class="btn btn-link btn-animate text-muted"
626 this.handleShowConfirmTransferSite
629 {i18n.t('transfer_site')}
633 <button class="btn btn-link btn-animate text-muted">
634 {i18n.t('are_you_sure')}
637 class="btn btn-link btn-animate text-muted"
640 this.handleTransferSite
646 class="btn btn-link btn-animate text-muted"
649 this.handleCancelShowConfirmTransferSite
661 {/* end of button group */}
666 {/* end of details */}
667 {this.state.showRemoveDialog && (
670 onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
674 class="form-control mr-2"
675 placeholder={i18n.t('reason')}
676 value={this.state.removeReason}
677 onInput={linkEvent(this, this.handleModRemoveReasonChange)}
679 <button type="submit" class="btn btn-secondary">
680 {i18n.t('remove_comment')}
684 {this.state.showBanDialog && (
685 <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
686 <div class="form-group row">
687 <label class="col-form-label">{i18n.t('reason')}</label>
690 class="form-control mr-2"
691 placeholder={i18n.t('reason')}
692 value={this.state.banReason}
693 onInput={linkEvent(this, this.handleModBanReasonChange)}
696 {/* TODO hold off on expires until later */}
697 {/* <div class="form-group row"> */}
698 {/* <label class="col-form-label">Expires</label> */}
699 {/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
701 <div class="form-group row">
702 <button type="submit" class="btn btn-secondary">
703 {i18n.t('ban')} {node.comment.creator_name}
708 {this.state.showReply && (
711 onReplyCancel={this.handleReplyCancel}
712 disabled={this.props.locked}
716 {node.children && !this.state.collapsed && (
718 nodes={node.children}
719 locked={this.props.locked}
720 moderators={this.props.moderators}
721 admins={this.props.admins}
722 postCreatorId={this.props.postCreatorId}
723 sort={this.props.sort}
724 sortType={this.props.sortType}
725 enableDownvotes={this.props.enableDownvotes}
728 {/* A collapsed clearfix */}
729 {this.state.collapsed && <div class="row col-12"></div>}
735 let node = this.props.node;
738 class="btn btn-link btn-animate text-muted"
739 to={`/post/${node.comment.post_id}/comment/${node.comment.id}`}
740 title={this.props.showContext ? i18n.t('show_context') : i18n.t('link')}
742 <svg class="icon icon-inline">
743 <use xlinkHref="#icon-link"></use>
751 <svg class="icon icon-spinner spin">
752 <use xlinkHref="#icon-spinner"></use>
757 get myComment(): boolean {
759 UserService.Instance.user &&
760 this.props.node.comment.creator_id == UserService.Instance.user.id
764 get isMod(): boolean {
766 this.props.moderators &&
768 this.props.moderators.map(m => m.user_id),
769 this.props.node.comment.creator_id
774 get isAdmin(): boolean {
778 this.props.admins.map(a => a.id),
779 this.props.node.comment.creator_id
784 get isPostCreator(): boolean {
785 return this.props.node.comment.creator_id == this.props.postCreatorId;
788 get canMod(): boolean {
789 if (this.props.admins && this.props.moderators) {
790 let adminsThenMods = this.props.admins
792 .concat(this.props.moderators.map(m => m.user_id));
795 UserService.Instance.user,
797 this.props.node.comment.creator_id
804 get canAdmin(): boolean {
808 UserService.Instance.user,
809 this.props.admins.map(a => a.id),
810 this.props.node.comment.creator_id
815 get amCommunityCreator(): boolean {
817 this.props.moderators &&
818 UserService.Instance.user &&
819 this.props.node.comment.creator_id != UserService.Instance.user.id &&
820 UserService.Instance.user.id == this.props.moderators[0].user_id
824 get amSiteCreator(): boolean {
827 UserService.Instance.user &&
828 this.props.node.comment.creator_id != UserService.Instance.user.id &&
829 UserService.Instance.user.id == this.props.admins[0].id
833 get commentUnlessRemoved(): string {
834 let node = this.props.node;
835 return node.comment.removed
836 ? `*${i18n.t('removed')}*`
837 : node.comment.deleted
838 ? `*${i18n.t('deleted')}*`
839 : node.comment.content;
842 handleReplyClick(i: CommentNode) {
843 i.state.showReply = true;
847 handleEditClick(i: CommentNode) {
848 i.state.showEdit = true;
852 handleDeleteClick(i: CommentNode) {
853 let deleteForm: DeleteCommentForm = {
854 edit_id: i.props.node.comment.id,
855 deleted: !i.props.node.comment.deleted,
858 WebSocketService.Instance.deleteComment(deleteForm);
861 handleSaveCommentClick(i: CommentNode) {
863 i.props.node.comment.saved == undefined
865 : !i.props.node.comment.saved;
866 let form: SaveCommentForm = {
867 comment_id: i.props.node.comment.id,
871 WebSocketService.Instance.saveComment(form);
873 i.state.saveLoading = true;
874 i.setState(this.state);
877 handleReplyCancel() {
878 this.state.showReply = false;
879 this.state.showEdit = false;
880 this.setState(this.state);
883 handleCommentUpvote(i: CommentNodeI) {
884 let new_vote = this.state.my_vote == 1 ? 0 : 1;
886 if (this.state.my_vote == 1) {
888 this.state.upvotes--;
889 } else if (this.state.my_vote == -1) {
890 this.state.downvotes--;
891 this.state.upvotes++;
892 this.state.score += 2;
894 this.state.upvotes++;
898 this.state.my_vote = new_vote;
900 let form: CommentLikeForm = {
901 comment_id: i.comment.id,
902 score: this.state.my_vote,
905 WebSocketService.Instance.likeComment(form);
906 this.setState(this.state);
910 handleCommentDownvote(i: CommentNodeI) {
911 let new_vote = this.state.my_vote == -1 ? 0 : -1;
913 if (this.state.my_vote == 1) {
914 this.state.score -= 2;
915 this.state.upvotes--;
916 this.state.downvotes++;
917 } else if (this.state.my_vote == -1) {
918 this.state.downvotes--;
921 this.state.downvotes++;
925 this.state.my_vote = new_vote;
927 let form: CommentLikeForm = {
928 comment_id: i.comment.id,
929 score: this.state.my_vote,
932 WebSocketService.Instance.likeComment(form);
933 this.setState(this.state);
937 handleModRemoveShow(i: CommentNode) {
938 i.state.showRemoveDialog = true;
942 handleModRemoveReasonChange(i: CommentNode, event: any) {
943 i.state.removeReason = event.target.value;
947 handleModRemoveSubmit(i: CommentNode) {
948 event.preventDefault();
949 let form: RemoveCommentForm = {
950 edit_id: i.props.node.comment.id,
951 removed: !i.props.node.comment.removed,
952 reason: i.state.removeReason,
955 WebSocketService.Instance.removeComment(form);
957 i.state.showRemoveDialog = false;
961 handleMarkRead(i: CommentNode) {
962 // if it has a user_mention_id field, then its a mention
963 if (i.props.node.comment.user_mention_id) {
964 let form: MarkUserMentionAsReadForm = {
965 user_mention_id: i.props.node.comment.user_mention_id,
966 read: !i.props.node.comment.read,
968 WebSocketService.Instance.markUserMentionAsRead(form);
970 let form: MarkCommentAsReadForm = {
971 edit_id: i.props.node.comment.id,
972 read: !i.props.node.comment.read,
975 WebSocketService.Instance.markCommentAsRead(form);
978 i.state.readLoading = true;
979 i.setState(this.state);
982 handleModBanFromCommunityShow(i: CommentNode) {
983 i.state.showBanDialog = !i.state.showBanDialog;
984 i.state.banType = BanType.Community;
988 handleModBanShow(i: CommentNode) {
989 i.state.showBanDialog = !i.state.showBanDialog;
990 i.state.banType = BanType.Site;
994 handleModBanReasonChange(i: CommentNode, event: any) {
995 i.state.banReason = event.target.value;
999 handleModBanExpiresChange(i: CommentNode, event: any) {
1000 i.state.banExpires = event.target.value;
1001 i.setState(i.state);
1004 handleModBanFromCommunitySubmit(i: CommentNode) {
1005 i.state.banType = BanType.Community;
1006 i.setState(i.state);
1007 i.handleModBanBothSubmit(i);
1010 handleModBanSubmit(i: CommentNode) {
1011 i.state.banType = BanType.Site;
1012 i.setState(i.state);
1013 i.handleModBanBothSubmit(i);
1016 handleModBanBothSubmit(i: CommentNode) {
1017 event.preventDefault();
1019 if (i.state.banType == BanType.Community) {
1020 let form: BanFromCommunityForm = {
1021 user_id: i.props.node.comment.creator_id,
1022 community_id: i.props.node.comment.community_id,
1023 ban: !i.props.node.comment.banned_from_community,
1024 reason: i.state.banReason,
1025 expires: getUnixTime(i.state.banExpires),
1027 WebSocketService.Instance.banFromCommunity(form);
1029 let form: BanUserForm = {
1030 user_id: i.props.node.comment.creator_id,
1031 ban: !i.props.node.comment.banned,
1032 reason: i.state.banReason,
1033 expires: getUnixTime(i.state.banExpires),
1035 WebSocketService.Instance.banUser(form);
1038 i.state.showBanDialog = false;
1039 i.setState(i.state);
1042 handleShowConfirmAppointAsMod(i: CommentNode) {
1043 i.state.showConfirmAppointAsMod = true;
1044 i.setState(i.state);
1047 handleCancelConfirmAppointAsMod(i: CommentNode) {
1048 i.state.showConfirmAppointAsMod = false;
1049 i.setState(i.state);
1052 handleAddModToCommunity(i: CommentNode) {
1053 let form: AddModToCommunityForm = {
1054 user_id: i.props.node.comment.creator_id,
1055 community_id: i.props.node.comment.community_id,
1058 WebSocketService.Instance.addModToCommunity(form);
1059 i.state.showConfirmAppointAsMod = false;
1060 i.setState(i.state);
1063 handleShowConfirmAppointAsAdmin(i: CommentNode) {
1064 i.state.showConfirmAppointAsAdmin = true;
1065 i.setState(i.state);
1068 handleCancelConfirmAppointAsAdmin(i: CommentNode) {
1069 i.state.showConfirmAppointAsAdmin = false;
1070 i.setState(i.state);
1073 handleAddAdmin(i: CommentNode) {
1074 let form: AddAdminForm = {
1075 user_id: i.props.node.comment.creator_id,
1078 WebSocketService.Instance.addAdmin(form);
1079 i.state.showConfirmAppointAsAdmin = false;
1080 i.setState(i.state);
1083 handleShowConfirmTransferCommunity(i: CommentNode) {
1084 i.state.showConfirmTransferCommunity = true;
1085 i.setState(i.state);
1088 handleCancelShowConfirmTransferCommunity(i: CommentNode) {
1089 i.state.showConfirmTransferCommunity = false;
1090 i.setState(i.state);
1093 handleTransferCommunity(i: CommentNode) {
1094 let form: TransferCommunityForm = {
1095 community_id: i.props.node.comment.community_id,
1096 user_id: i.props.node.comment.creator_id,
1098 WebSocketService.Instance.transferCommunity(form);
1099 i.state.showConfirmTransferCommunity = false;
1100 i.setState(i.state);
1103 handleShowConfirmTransferSite(i: CommentNode) {
1104 i.state.showConfirmTransferSite = true;
1105 i.setState(i.state);
1108 handleCancelShowConfirmTransferSite(i: CommentNode) {
1109 i.state.showConfirmTransferSite = false;
1110 i.setState(i.state);
1113 handleTransferSite(i: CommentNode) {
1114 let form: TransferSiteForm = {
1115 user_id: i.props.node.comment.creator_id,
1117 WebSocketService.Instance.transferSite(form);
1118 i.state.showConfirmTransferSite = false;
1119 i.setState(i.state);
1122 get isCommentNew(): boolean {
1123 let now = moment.utc().subtract(10, 'minutes');
1124 let then = moment.utc(this.props.node.comment.published);
1125 return now.isBefore(then);
1128 handleCommentCollapse(i: CommentNode) {
1129 i.state.collapsed = !i.state.collapsed;
1130 i.setState(i.state);
1133 handleViewSource(i: CommentNode) {
1134 i.state.viewSource = !i.state.viewSource;
1135 i.setState(i.state);
1138 handleShowAdvanced(i: CommentNode) {
1139 i.state.showAdvanced = !i.state.showAdvanced;
1140 i.setState(i.state);
1145 if (this.state.my_vote == 1) {
1147 } else if (this.state.my_vote == -1) {
1148 return 'text-danger';
1150 return 'text-muted';
1154 get pointsTippy(): string {
1155 let points = i18n.t('number_of_points', {
1156 count: this.state.score,
1159 let upvotes = i18n.t('number_of_upvotes', {
1160 count: this.state.upvotes,
1163 let downvotes = i18n.t('number_of_downvotes', {
1164 count: this.state.downvotes,
1167 return `${points} • ${upvotes} • ${downvotes}`;