]> Untitled Git - lemmy-ui.git/blob - src/shared/components/post/post-listing.tsx
Feature/user community block (#362)
[lemmy-ui.git] / src / shared / components / post / post-listing.tsx
1 import { Component, linkEvent } from "inferno";
2 import { Link } from "inferno-router";
3 import {
4   AddAdmin,
5   AddModToCommunity,
6   BanFromCommunity,
7   BanPerson,
8   BlockPerson,
9   CommunityModeratorView,
10   CreatePostLike,
11   DeletePost,
12   LockPost,
13   PersonViewSafe,
14   PostView,
15   RemovePost,
16   SavePost,
17   StickyPost,
18   TransferCommunity,
19   TransferSite,
20 } from "lemmy-js-client";
21 import { externalHost } from "../../env";
22 import { i18n } from "../../i18next";
23 import { BanType } from "../../interfaces";
24 import { UserService, WebSocketService } from "../../services";
25 import {
26   authField,
27   canMod,
28   getUnixTime,
29   hostname,
30   isImage,
31   isMod,
32   isVideo,
33   md,
34   mdToHtml,
35   previewLines,
36   setupTippy,
37   showScores,
38   wsClient,
39 } from "../../utils";
40 import { Icon } from "../common/icon";
41 import { MomentTime } from "../common/moment-time";
42 import { PictrsImage } from "../common/pictrs-image";
43 import { CommunityLink } from "../community/community-link";
44 import { PersonListing } from "../person/person-listing";
45 import { MetadataCard } from "./metadata-card";
46 import { PostForm } from "./post-form";
47
48 interface PostListingState {
49   showEdit: boolean;
50   showRemoveDialog: boolean;
51   removeReason: string;
52   showBanDialog: boolean;
53   removeData: boolean;
54   banReason: string;
55   banExpires: string;
56   banType: BanType;
57   showConfirmTransferSite: boolean;
58   showConfirmTransferCommunity: boolean;
59   imageExpanded: boolean;
60   viewSource: boolean;
61   showAdvanced: boolean;
62   showMoreMobile: boolean;
63   showBody: boolean;
64   my_vote: number;
65   score: number;
66   upvotes: number;
67   downvotes: number;
68 }
69
70 interface PostListingProps {
71   post_view: PostView;
72   duplicates?: PostView[];
73   showCommunity?: boolean;
74   showBody?: boolean;
75   moderators?: CommunityModeratorView[];
76   admins?: PersonViewSafe[];
77   enableDownvotes: boolean;
78   enableNsfw: boolean;
79 }
80
81 export class PostListing extends Component<PostListingProps, PostListingState> {
82   private emptyState: PostListingState = {
83     showEdit: false,
84     showRemoveDialog: false,
85     removeReason: null,
86     showBanDialog: false,
87     removeData: false,
88     banReason: null,
89     banExpires: null,
90     banType: BanType.Community,
91     showConfirmTransferSite: false,
92     showConfirmTransferCommunity: false,
93     imageExpanded: false,
94     viewSource: false,
95     showAdvanced: false,
96     showMoreMobile: false,
97     showBody: false,
98     my_vote: this.props.post_view.my_vote,
99     score: this.props.post_view.counts.score,
100     upvotes: this.props.post_view.counts.upvotes,
101     downvotes: this.props.post_view.counts.downvotes,
102   };
103
104   constructor(props: any, context: any) {
105     super(props, context);
106
107     this.state = this.emptyState;
108     this.handlePostLike = this.handlePostLike.bind(this);
109     this.handlePostDisLike = this.handlePostDisLike.bind(this);
110     this.handleEditPost = this.handleEditPost.bind(this);
111     this.handleEditCancel = this.handleEditCancel.bind(this);
112   }
113
114   componentWillReceiveProps(nextProps: PostListingProps) {
115     this.state.my_vote = nextProps.post_view.my_vote;
116     this.state.upvotes = nextProps.post_view.counts.upvotes;
117     this.state.downvotes = nextProps.post_view.counts.downvotes;
118     this.state.score = nextProps.post_view.counts.score;
119     if (this.props.post_view.post.id !== nextProps.post_view.post.id) {
120       this.state.imageExpanded = false;
121     }
122     this.setState(this.state);
123   }
124
125   render() {
126     return (
127       <div class="">
128         {!this.state.showEdit ? (
129           <>
130             {this.listing()}
131             {this.body()}
132           </>
133         ) : (
134           <div class="col-12">
135             <PostForm
136               post_view={this.props.post_view}
137               onEdit={this.handleEditPost}
138               onCancel={this.handleEditCancel}
139               enableNsfw={this.props.enableNsfw}
140               enableDownvotes={this.props.enableDownvotes}
141             />
142           </div>
143         )}
144       </div>
145     );
146   }
147
148   body() {
149     let post = this.props.post_view.post;
150     return (
151       <div class="row">
152         <div class="col-12">
153           {post.url && this.showBody && post.embed_title && (
154             <MetadataCard post={post} />
155           )}
156           {this.showBody &&
157             post.body &&
158             (this.state.viewSource ? (
159               <pre>{post.body}</pre>
160             ) : (
161               <div
162                 className="md-div"
163                 dangerouslySetInnerHTML={mdToHtml(post.body)}
164               />
165             ))}
166         </div>
167       </div>
168     );
169   }
170
171   imgThumb(src: string) {
172     let post_view = this.props.post_view;
173     return (
174       <PictrsImage
175         src={src}
176         thumbnail
177         alt=""
178         nsfw={post_view.post.nsfw || post_view.community.nsfw}
179       />
180     );
181   }
182
183   getImageSrc(): string {
184     let post = this.props.post_view.post;
185     if (isImage(post.url)) {
186       if (post.url.includes("pictrs")) {
187         return post.url;
188       } else if (post.thumbnail_url) {
189         return post.thumbnail_url;
190       } else {
191         return post.url;
192       }
193     } else if (post.thumbnail_url) {
194       return post.thumbnail_url;
195     } else {
196       return null;
197     }
198   }
199
200   thumbnail() {
201     let post = this.props.post_view.post;
202
203     if (isImage(post.url)) {
204       return (
205         <div
206           class="float-right text-body pointer d-inline-block position-relative mb-2"
207           data-tippy-content={i18n.t("expand_here")}
208           onClick={linkEvent(this, this.handleImageExpandClick)}
209           role="button"
210           aria-label={i18n.t("expand_here")}
211         >
212           {this.imgThumb(this.getImageSrc())}
213           <Icon icon="image" classes="mini-overlay" />
214         </div>
215       );
216     } else if (post.thumbnail_url) {
217       return (
218         <a
219           class="float-right text-body d-inline-block position-relative mb-2"
220           href={post.url}
221           rel="noopener"
222           title={post.url}
223         >
224           {this.imgThumb(this.getImageSrc())}
225           <Icon icon="external-link" classes="mini-overlay" />
226         </a>
227       );
228     } else if (post.url) {
229       if (isVideo(post.url)) {
230         return (
231           <div class="embed-responsive embed-responsive-16by9">
232             <video
233               playsinline
234               muted
235               loop
236               controls
237               class="embed-responsive-item"
238             >
239               <source src={post.url} type="video/mp4" />
240             </video>
241           </div>
242         );
243       } else {
244         return (
245           <a
246             className="text-body"
247             href={post.url}
248             title={post.url}
249             rel="noopener"
250           >
251             <div class="thumbnail rounded bg-light d-flex justify-content-center">
252               <Icon icon="external-link" classes="d-flex align-items-center" />
253             </div>
254           </a>
255         );
256       }
257     } else {
258       return (
259         <Link
260           className="text-body"
261           to={`/post/${post.id}`}
262           title={i18n.t("comments")}
263         >
264           <div class="thumbnail rounded bg-light d-flex justify-content-center">
265             <Icon icon="message-square" classes="d-flex align-items-center" />
266           </div>
267         </Link>
268       );
269     }
270   }
271
272   createdLine() {
273     let post_view = this.props.post_view;
274     return (
275       <ul class="list-inline mb-1 text-muted small">
276         <li className="list-inline-item">
277           <PersonListing person={post_view.creator} />
278
279           {this.isMod && (
280             <span className="mx-1 badge badge-light">{i18n.t("mod")}</span>
281           )}
282           {this.isAdmin && (
283             <span className="mx-1 badge badge-light">{i18n.t("admin")}</span>
284           )}
285           {(post_view.creator_banned_from_community ||
286             post_view.creator.banned) && (
287             <span className="mx-1 badge badge-danger">{i18n.t("banned")}</span>
288           )}
289           {post_view.creator_blocked && (
290             <span className="mx-1 badge badge-danger">{"blocked"}</span>
291           )}
292           {this.props.showCommunity && (
293             <span>
294               <span class="mx-1"> {i18n.t("to")} </span>
295               <CommunityLink community={post_view.community} />
296             </span>
297           )}
298         </li>
299         <li className="list-inline-item">•</li>
300         {post_view.post.url && !(hostname(post_view.post.url) == externalHost) && (
301           <>
302             <li className="list-inline-item">
303               <a
304                 className="text-muted font-italic"
305                 href={post_view.post.url}
306                 title={post_view.post.url}
307                 rel="noopener"
308               >
309                 {hostname(post_view.post.url)}
310               </a>
311             </li>
312             <li className="list-inline-item">•</li>
313           </>
314         )}
315         <li className="list-inline-item">
316           <span>
317             <MomentTime data={post_view.post} />
318           </span>
319         </li>
320         {post_view.post.body && (
321           <>
322             <li className="list-inline-item">•</li>
323             <li className="list-inline-item">
324               <button
325                 className="text-muted btn btn-sm btn-link p-0"
326                 data-tippy-content={md.render(
327                   previewLines(post_view.post.body)
328                 )}
329                 data-tippy-allowHtml={true}
330                 onClick={linkEvent(this, this.handleShowBody)}
331               >
332                 <Icon icon="book-open" classes="icon-inline mr-1" />
333               </button>
334             </li>
335           </>
336         )}
337       </ul>
338     );
339   }
340
341   voteBar() {
342     return (
343       <div className={`vote-bar col-1 pr-0 small text-center`}>
344         <button
345           className={`btn-animate btn btn-link p-0 ${
346             this.state.my_vote == 1 ? "text-info" : "text-muted"
347           }`}
348           onClick={linkEvent(this, this.handlePostLike)}
349           data-tippy-content={i18n.t("upvote")}
350           aria-label={i18n.t("upvote")}
351         >
352           <Icon icon="arrow-up1" classes="upvote" />
353         </button>
354         {showScores() ? (
355           <div
356             class={`unselectable pointer font-weight-bold text-muted px-1`}
357             data-tippy-content={this.pointsTippy}
358           >
359             {this.state.score}
360           </div>
361         ) : (
362           <div class="p-1"></div>
363         )}
364         {this.props.enableDownvotes && (
365           <button
366             className={`btn-animate btn btn-link p-0 ${
367               this.state.my_vote == -1 ? "text-danger" : "text-muted"
368             }`}
369             onClick={linkEvent(this, this.handlePostDisLike)}
370             data-tippy-content={i18n.t("downvote")}
371             aria-label={i18n.t("downvote")}
372           >
373             <Icon icon="arrow-down1" classes="downvote" />
374           </button>
375         )}
376       </div>
377     );
378   }
379
380   postTitleLine() {
381     let post = this.props.post_view.post;
382     return (
383       <div className="post-title overflow-hidden">
384         <h5>
385           {this.showBody && post.url ? (
386             <a
387               className={!post.stickied ? "text-body" : "text-primary"}
388               href={post.url}
389               title={post.url}
390               rel="noopener"
391             >
392               {post.name}
393             </a>
394           ) : (
395             <Link
396               className={!post.stickied ? "text-body" : "text-primary"}
397               to={`/post/${post.id}`}
398               title={i18n.t("comments")}
399             >
400               {post.name}
401             </Link>
402           )}
403           {(isImage(post.url) || post.thumbnail_url) &&
404             (!this.state.imageExpanded ? (
405               <button
406                 class="btn btn-link text-monospace text-muted small d-inline-block ml-2"
407                 data-tippy-content={i18n.t("expand_here")}
408                 onClick={linkEvent(this, this.handleImageExpandClick)}
409               >
410                 <Icon icon="plus-square" classes="icon-inline" />
411               </button>
412             ) : (
413               <span>
414                 <button
415                   class="btn btn-link text-monospace text-muted small d-inline-block ml-2"
416                   onClick={linkEvent(this, this.handleImageExpandClick)}
417                 >
418                   <Icon icon="minus-square" classes="icon-inline" />
419                 </button>
420                 <div>
421                   <button
422                     class="btn btn-link d-inline-block"
423                     onClick={linkEvent(this, this.handleImageExpandClick)}
424                   >
425                     <PictrsImage src={this.getImageSrc()} />
426                   </button>
427                 </div>
428               </span>
429             ))}
430           {post.removed && (
431             <small className="ml-2 text-muted font-italic">
432               {i18n.t("removed")}
433             </small>
434           )}
435           {post.deleted && (
436             <small
437               className="unselectable pointer ml-2 text-muted font-italic"
438               data-tippy-content={i18n.t("deleted")}
439             >
440               <Icon icon="trash" classes="icon-inline text-danger" />
441             </small>
442           )}
443           {post.locked && (
444             <small
445               className="unselectable pointer ml-2 text-muted font-italic"
446               data-tippy-content={i18n.t("locked")}
447             >
448               <Icon icon="lock" classes="icon-inline text-danger" />
449             </small>
450           )}
451           {post.stickied && (
452             <small
453               className="unselectable pointer ml-2 text-muted font-italic"
454               data-tippy-content={i18n.t("stickied")}
455             >
456               <Icon icon="pin" classes="icon-inline text-primary" />
457             </small>
458           )}
459           {post.nsfw && (
460             <small className="ml-2 text-muted font-italic">
461               {i18n.t("nsfw")}
462             </small>
463           )}
464         </h5>
465       </div>
466     );
467   }
468
469   commentsLine(mobile = false) {
470     let post_view = this.props.post_view;
471     return (
472       <div class="d-flex justify-content-between justify-content-lg-start flex-wrap text-muted font-weight-bold mb-1">
473         <button class="btn btn-link text-muted p-0">
474           <Link
475             className="text-muted small"
476             title={i18n.t("number_of_comments", {
477               count: post_view.counts.comments,
478             })}
479             to={`/post/${post_view.post.id}?scrollToComments=true`}
480           >
481             <Icon icon="message-square" classes="icon-inline mr-1" />
482             {i18n.t("number_of_comments", {
483               count: post_view.counts.comments,
484             })}
485           </Link>
486         </button>
487         {!mobile && (
488           <>
489             {this.state.downvotes !== 0 && showScores() && (
490               <button
491                 class="btn text-muted py-0 pr-0"
492                 data-tippy-content={this.pointsTippy}
493                 aria-label={i18n.t("downvote")}
494               >
495                 <small>
496                   <Icon icon="arrow-down1" classes="icon-inline mr-1" />
497                   <span>{this.state.downvotes}</span>
498                 </small>
499               </button>
500             )}
501             {!this.showBody && (
502               <button
503                 class="btn btn-link btn-animate text-muted py-0"
504                 onClick={linkEvent(this, this.handleSavePostClick)}
505                 data-tippy-content={
506                   post_view.saved ? i18n.t("unsave") : i18n.t("save")
507                 }
508                 aria-label={post_view.saved ? i18n.t("unsave") : i18n.t("save")}
509               >
510                 <small>
511                   <Icon
512                     icon="star"
513                     classes={`icon-inline ${post_view.saved && "text-warning"}`}
514                   />
515                 </small>
516               </button>
517             )}
518           </>
519         )}
520         {/* This is an expanding spacer for mobile */}
521         <div className="flex-grow-1"></div>
522         {mobile && (
523           <>
524             <div>
525               {showScores() ? (
526                 <button
527                   className={`btn-animate btn py-0 px-1 ${
528                     this.state.my_vote == 1 ? "text-info" : "text-muted"
529                   }`}
530                   data-tippy-content={this.pointsTippy}
531                   onClick={linkEvent(this, this.handlePostLike)}
532                   aria-label={i18n.t("upvote")}
533                 >
534                   <Icon icon="arrow-up1" classes="icon-inline small mr-2" />
535                   {this.state.upvotes}
536                 </button>
537               ) : (
538                 <button
539                   className={`btn-animate btn py-0 px-1 ${
540                     this.state.my_vote == 1 ? "text-info" : "text-muted"
541                   }`}
542                   onClick={linkEvent(this, this.handlePostLike)}
543                   aria-label={i18n.t("upvote")}
544                 >
545                   <Icon icon="arrow-up1" classes="icon-inline small" />
546                 </button>
547               )}
548               {this.props.enableDownvotes &&
549                 (showScores() ? (
550                   <button
551                     className={`ml-2 btn-animate btn py-0 pl-1 ${
552                       this.state.my_vote == -1 ? "text-danger" : "text-muted"
553                     }`}
554                     onClick={linkEvent(this, this.handlePostDisLike)}
555                     data-tippy-content={this.pointsTippy}
556                     aria-label={i18n.t("downvote")}
557                   >
558                     <Icon icon="arrow-down1" classes="icon-inline small mr-2" />
559                     {this.state.downvotes !== 0 && (
560                       <span>{this.state.downvotes}</span>
561                     )}
562                   </button>
563                 ) : (
564                   <button
565                     className={`ml-2 btn-animate btn py-0 pl-1 ${
566                       this.state.my_vote == -1 ? "text-danger" : "text-muted"
567                     }`}
568                     onClick={linkEvent(this, this.handlePostDisLike)}
569                     aria-label={i18n.t("downvote")}
570                   >
571                     <Icon icon="arrow-down1" classes="icon-inline small" />
572                   </button>
573                 ))}
574             </div>
575             <button
576               class="btn btn-link btn-animate text-muted py-0 pl-1 pr-0"
577               onClick={linkEvent(this, this.handleSavePostClick)}
578               aria-label={post_view.saved ? i18n.t("unsave") : i18n.t("save")}
579               data-tippy-content={
580                 post_view.saved ? i18n.t("unsave") : i18n.t("save")
581               }
582             >
583               <Icon
584                 icon="star"
585                 classes={`icon-inline ${post_view.saved && "text-warning"}`}
586               />
587             </button>
588
589             {!this.state.showMoreMobile && this.showBody && (
590               <button
591                 class="btn btn-link btn-animate text-muted py-0"
592                 onClick={linkEvent(this, this.handleShowMoreMobile)}
593                 aria-label={i18n.t("more")}
594                 data-tippy-content={i18n.t("more")}
595               >
596                 <Icon icon="more-vertical" classes="icon-inline" />
597               </button>
598             )}
599             {this.state.showMoreMobile && this.postActions(mobile)}
600           </>
601         )}
602       </div>
603     );
604   }
605
606   duplicatesLine() {
607     let dupes = this.props.duplicates;
608     return (
609       dupes &&
610       dupes.length > 0 && (
611         <ul class="list-inline mb-1 small text-muted">
612           <>
613             <li className="list-inline-item mr-2">
614               {i18n.t("cross_posted_to")}
615             </li>
616             {dupes.map(pv => (
617               <li className="list-inline-item mr-2">
618                 <Link to={`/post/${pv.post.id}`}>
619                   {pv.community.local
620                     ? pv.community.name
621                     : `${pv.community.name}@${hostname(pv.community.actor_id)}`}
622                 </Link>
623               </li>
624             ))}
625           </>
626         </ul>
627       )
628     );
629   }
630
631   postActions(mobile = false) {
632     let post_view = this.props.post_view;
633     return (
634       UserService.Instance.myUserInfo && (
635         <>
636           {this.showBody && (
637             <>
638               {!mobile && (
639                 <button
640                   class="btn btn-link btn-animate text-muted py-0 pl-0"
641                   onClick={linkEvent(this, this.handleSavePostClick)}
642                   data-tippy-content={
643                     post_view.saved ? i18n.t("unsave") : i18n.t("save")
644                   }
645                   aria-label={
646                     post_view.saved ? i18n.t("unsave") : i18n.t("save")
647                   }
648                 >
649                   <Icon
650                     icon="star"
651                     classes={`icon-inline ${post_view.saved && "text-warning"}`}
652                   />
653                 </button>
654               )}
655               <Link
656                 className="btn btn-link btn-animate text-muted py-0"
657                 to={`/create_post${this.crossPostParams}`}
658                 title={i18n.t("cross_post")}
659               >
660                 <Icon icon="copy" classes="icon-inline" />
661               </Link>
662               {!this.myPost && (
663                 <button
664                   class="btn btn-link btn-animate text-muted py-0"
665                   onClick={linkEvent(this, this.handleBlockUserClick)}
666                   data-tippy-content={i18n.t("block_user")}
667                   aria-label={i18n.t("block_user")}
668                 >
669                   <Icon icon="slash" classes="icon-inline" />
670                 </button>
671               )}
672             </>
673           )}
674           {this.myPost && this.showBody && (
675             <>
676               <button
677                 class="btn btn-link btn-animate text-muted py-0"
678                 onClick={linkEvent(this, this.handleEditClick)}
679                 data-tippy-content={i18n.t("edit")}
680                 aria-label={i18n.t("edit")}
681               >
682                 <Icon icon="edit" classes="icon-inline" />
683               </button>
684               <button
685                 class="btn btn-link btn-animate text-muted py-0"
686                 onClick={linkEvent(this, this.handleDeleteClick)}
687                 data-tippy-content={
688                   !post_view.post.deleted ? i18n.t("delete") : i18n.t("restore")
689                 }
690                 aria-label={
691                   !post_view.post.deleted ? i18n.t("delete") : i18n.t("restore")
692                 }
693               >
694                 <Icon
695                   icon="trash"
696                   classes={`icon-inline ${
697                     post_view.post.deleted && "text-danger"
698                   }`}
699                 />
700               </button>
701             </>
702           )}
703
704           {!this.state.showAdvanced && this.showBody ? (
705             <button
706               class="btn btn-link btn-animate text-muted py-0"
707               onClick={linkEvent(this, this.handleShowAdvanced)}
708               data-tippy-content={i18n.t("more")}
709               aria-label={i18n.t("more")}
710             >
711               <Icon icon="more-vertical" classes="icon-inline" />
712             </button>
713           ) : (
714             <>
715               {this.showBody && post_view.post.body && (
716                 <button
717                   class="btn btn-link btn-animate text-muted py-0"
718                   onClick={linkEvent(this, this.handleViewSource)}
719                   data-tippy-content={i18n.t("view_source")}
720                   aria-label={i18n.t("view_source")}
721                 >
722                   <Icon
723                     icon="file-text"
724                     classes={`icon-inline ${
725                       this.state.viewSource && "text-success"
726                     }`}
727                   />
728                 </button>
729               )}
730               {this.canModOnSelf && (
731                 <>
732                   <button
733                     class="btn btn-link btn-animate text-muted py-0"
734                     onClick={linkEvent(this, this.handleModLock)}
735                     data-tippy-content={
736                       post_view.post.locked ? i18n.t("unlock") : i18n.t("lock")
737                     }
738                     aria-label={
739                       post_view.post.locked ? i18n.t("unlock") : i18n.t("lock")
740                     }
741                   >
742                     <Icon
743                       icon="lock"
744                       classes={`icon-inline ${
745                         post_view.post.locked && "text-danger"
746                       }`}
747                     />
748                   </button>
749                   <button
750                     class="btn btn-link btn-animate text-muted py-0"
751                     onClick={linkEvent(this, this.handleModSticky)}
752                     data-tippy-content={
753                       post_view.post.stickied
754                         ? i18n.t("unsticky")
755                         : i18n.t("sticky")
756                     }
757                     aria-label={
758                       post_view.post.stickied
759                         ? i18n.t("unsticky")
760                         : i18n.t("sticky")
761                     }
762                   >
763                     <Icon
764                       icon="pin"
765                       classes={`icon-inline ${
766                         post_view.post.stickied && "text-success"
767                       }`}
768                     />
769                   </button>
770                 </>
771               )}
772               {/* Mods can ban from community, and appoint as mods to community */}
773               {(this.canMod || this.canAdmin) &&
774                 (!post_view.post.removed ? (
775                   <button
776                     class="btn btn-link btn-animate text-muted py-0"
777                     onClick={linkEvent(this, this.handleModRemoveShow)}
778                     aria-label={i18n.t("remove")}
779                   >
780                     {i18n.t("remove")}
781                   </button>
782                 ) : (
783                   <button
784                     class="btn btn-link btn-animate text-muted py-0"
785                     onClick={linkEvent(this, this.handleModRemoveSubmit)}
786                     aria-label={i18n.t("restore")}
787                   >
788                     {i18n.t("restore")}
789                   </button>
790                 ))}
791               {this.canMod && (
792                 <>
793                   {!this.isMod &&
794                     (!post_view.creator_banned_from_community ? (
795                       <button
796                         class="btn btn-link btn-animate text-muted py-0"
797                         onClick={linkEvent(
798                           this,
799                           this.handleModBanFromCommunityShow
800                         )}
801                         aria-label={i18n.t("ban")}
802                       >
803                         {i18n.t("ban")}
804                       </button>
805                     ) : (
806                       <button
807                         class="btn btn-link btn-animate text-muted py-0"
808                         onClick={linkEvent(
809                           this,
810                           this.handleModBanFromCommunitySubmit
811                         )}
812                         aria-label={i18n.t("unban")}
813                       >
814                         {i18n.t("unban")}
815                       </button>
816                     ))}
817                   {!post_view.creator_banned_from_community && (
818                     <button
819                       class="btn btn-link btn-animate text-muted py-0"
820                       onClick={linkEvent(this, this.handleAddModToCommunity)}
821                       aria-label={
822                         this.isMod
823                           ? i18n.t("remove_as_mod")
824                           : i18n.t("appoint_as_mod")
825                       }
826                     >
827                       {this.isMod
828                         ? i18n.t("remove_as_mod")
829                         : i18n.t("appoint_as_mod")}
830                     </button>
831                   )}
832                 </>
833               )}
834               {/* Community creators and admins can transfer community to another mod */}
835               {(this.amCommunityCreator || this.canAdmin) &&
836                 this.isMod &&
837                 (!this.state.showConfirmTransferCommunity ? (
838                   <button
839                     class="btn btn-link btn-animate text-muted py-0"
840                     onClick={linkEvent(
841                       this,
842                       this.handleShowConfirmTransferCommunity
843                     )}
844                     aria-label={i18n.t("transfer_community")}
845                   >
846                     {i18n.t("transfer_community")}
847                   </button>
848                 ) : (
849                   <>
850                     <button
851                       class="d-inline-block mr-1 btn btn-link btn-animate text-muted py-0"
852                       aria-label={i18n.t("are_you_sure")}
853                     >
854                       {i18n.t("are_you_sure")}
855                     </button>
856                     <button
857                       class="btn btn-link btn-animate text-muted py-0 d-inline-block mr-1"
858                       aria-label={i18n.t("yes")}
859                       onClick={linkEvent(this, this.handleTransferCommunity)}
860                     >
861                       {i18n.t("yes")}
862                     </button>
863                     <button
864                       class="btn btn-link btn-animate text-muted py-0 d-inline-block"
865                       onClick={linkEvent(
866                         this,
867                         this.handleCancelShowConfirmTransferCommunity
868                       )}
869                       aria-label={i18n.t("no")}
870                     >
871                       {i18n.t("no")}
872                     </button>
873                   </>
874                 ))}
875               {/* Admins can ban from all, and appoint other admins */}
876               {this.canAdmin && (
877                 <>
878                   {!this.isAdmin &&
879                     (!post_view.creator.banned ? (
880                       <button
881                         class="btn btn-link btn-animate text-muted py-0"
882                         onClick={linkEvent(this, this.handleModBanShow)}
883                         aria-label={i18n.t("ban_from_site")}
884                       >
885                         {i18n.t("ban_from_site")}
886                       </button>
887                     ) : (
888                       <button
889                         class="btn btn-link btn-animate text-muted py-0"
890                         onClick={linkEvent(this, this.handleModBanSubmit)}
891                         aria-label={i18n.t("unban_from_site")}
892                       >
893                         {i18n.t("unban_from_site")}
894                       </button>
895                     ))}
896                   {!post_view.creator.banned && post_view.creator.local && (
897                     <button
898                       class="btn btn-link btn-animate text-muted py-0"
899                       onClick={linkEvent(this, this.handleAddAdmin)}
900                       aria-label={
901                         this.isAdmin
902                           ? i18n.t("remove_as_admin")
903                           : i18n.t("appoint_as_admin")
904                       }
905                     >
906                       {this.isAdmin
907                         ? i18n.t("remove_as_admin")
908                         : i18n.t("appoint_as_admin")}
909                     </button>
910                   )}
911                 </>
912               )}
913               {/* Site Creator can transfer to another admin */}
914               {this.amSiteCreator &&
915                 this.isAdmin &&
916                 (!this.state.showConfirmTransferSite ? (
917                   <button
918                     class="btn btn-link btn-animate text-muted py-0"
919                     onClick={linkEvent(
920                       this,
921                       this.handleShowConfirmTransferSite
922                     )}
923                     aria-label={i18n.t("transfer_site")}
924                   >
925                     {i18n.t("transfer_site")}
926                   </button>
927                 ) : (
928                   <>
929                     <button
930                       class="btn btn-link btn-animate text-muted py-0 d-inline-block mr-1"
931                       aria-label={i18n.t("are_you_sure")}
932                     >
933                       {i18n.t("are_you_sure")}
934                     </button>
935                     <button
936                       class="btn btn-link btn-animate text-muted py-0 d-inline-block mr-1"
937                       onClick={linkEvent(this, this.handleTransferSite)}
938                       aria-label={i18n.t("yes")}
939                     >
940                       {i18n.t("yes")}
941                     </button>
942                     <button
943                       class="btn btn-link btn-animate text-muted py-0 d-inline-block"
944                       onClick={linkEvent(
945                         this,
946                         this.handleCancelShowConfirmTransferSite
947                       )}
948                       aria-label={i18n.t("no")}
949                     >
950                       {i18n.t("no")}
951                     </button>
952                   </>
953                 ))}
954             </>
955           )}
956         </>
957       )
958     );
959   }
960
961   removeAndBanDialogs() {
962     let post = this.props.post_view;
963     return (
964       <>
965         {this.state.showRemoveDialog && (
966           <form
967             class="form-inline"
968             onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
969           >
970             <label class="sr-only" htmlFor="post-listing-remove-reason">
971               {i18n.t("reason")}
972             </label>
973             <input
974               type="text"
975               id="post-listing-remove-reason"
976               class="form-control mr-2"
977               placeholder={i18n.t("reason")}
978               value={this.state.removeReason}
979               onInput={linkEvent(this, this.handleModRemoveReasonChange)}
980             />
981             <button
982               type="submit"
983               class="btn btn-secondary"
984               aria-label={i18n.t("remove_post")}
985             >
986               {i18n.t("remove_post")}
987             </button>
988           </form>
989         )}
990         {this.state.showBanDialog && (
991           <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
992             <div class="form-group row">
993               <label class="col-form-label" htmlFor="post-listing-ban-reason">
994                 {i18n.t("reason")}
995               </label>
996               <input
997                 type="text"
998                 id="post-listing-ban-reason"
999                 class="form-control mr-2"
1000                 placeholder={i18n.t("reason")}
1001                 value={this.state.banReason}
1002                 onInput={linkEvent(this, this.handleModBanReasonChange)}
1003               />
1004               <div class="form-group">
1005                 <div class="form-check">
1006                   <input
1007                     class="form-check-input"
1008                     id="mod-ban-remove-data"
1009                     type="checkbox"
1010                     checked={this.state.removeData}
1011                     onChange={linkEvent(this, this.handleModRemoveDataChange)}
1012                   />
1013                   <label
1014                     class="form-check-label"
1015                     htmlFor="mod-ban-remove-data"
1016                     title={i18n.t("remove_content_more")}
1017                   >
1018                     {i18n.t("remove_content")}
1019                   </label>
1020                 </div>
1021               </div>
1022             </div>
1023             {/* TODO hold off on expires until later */}
1024             {/* <div class="form-group row"> */}
1025             {/*   <label class="col-form-label">Expires</label> */}
1026             {/*   <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
1027             {/* </div> */}
1028             <div class="form-group row">
1029               <button
1030                 type="submit"
1031                 class="btn btn-secondary"
1032                 aria-label={i18n.t("ban")}
1033               >
1034                 {i18n.t("ban")} {post.creator.name}
1035               </button>
1036             </div>
1037           </form>
1038         )}
1039       </>
1040     );
1041   }
1042
1043   mobileThumbnail() {
1044     let post = this.props.post_view.post;
1045     return post.thumbnail_url || isImage(post.url) ? (
1046       <div class="row">
1047         <div className={`${this.state.imageExpanded ? "col-12" : "col-8"}`}>
1048           {this.postTitleLine()}
1049         </div>
1050         <div class="col-4">
1051           {/* Post body prev or thumbnail */}
1052           {!this.state.imageExpanded && this.thumbnail()}
1053         </div>
1054       </div>
1055     ) : (
1056       this.postTitleLine()
1057     );
1058   }
1059
1060   showMobilePreview() {
1061     let post = this.props.post_view.post;
1062     return (
1063       post.body &&
1064       !this.showBody && (
1065         <div
1066           className="md-div mb-1"
1067           dangerouslySetInnerHTML={{
1068             __html: md.render(previewLines(post.body)),
1069           }}
1070         />
1071       )
1072     );
1073   }
1074
1075   listing() {
1076     return (
1077       <>
1078         {/* The mobile view*/}
1079         <div class="d-block d-sm-none">
1080           <div class="row">
1081             <div class="col-12">
1082               {this.createdLine()}
1083
1084               {/* If it has a thumbnail, do a right aligned thumbnail */}
1085               {this.mobileThumbnail()}
1086
1087               {/* Show a preview of the post body */}
1088               {this.showMobilePreview()}
1089
1090               {this.commentsLine(true)}
1091               {this.duplicatesLine()}
1092               {this.removeAndBanDialogs()}
1093             </div>
1094           </div>
1095         </div>
1096
1097         {/* The larger view*/}
1098         <div class="d-none d-sm-block">
1099           <div class="row">
1100             {this.voteBar()}
1101             {!this.state.imageExpanded && (
1102               <div class="col-sm-2 pr-0">
1103                 <div class="">{this.thumbnail()}</div>
1104               </div>
1105             )}
1106             <div
1107               class={`${
1108                 this.state.imageExpanded ? "col-12" : "col-12 col-sm-9"
1109               }`}
1110             >
1111               <div class="row">
1112                 <div className="col-12">
1113                   {this.postTitleLine()}
1114                   {this.createdLine()}
1115                   {this.commentsLine()}
1116                   {this.duplicatesLine()}
1117                   {this.postActions()}
1118                   {this.removeAndBanDialogs()}
1119                 </div>
1120               </div>
1121             </div>
1122           </div>
1123         </div>
1124       </>
1125     );
1126   }
1127
1128   private get myPost(): boolean {
1129     return (
1130       UserService.Instance.myUserInfo &&
1131       this.props.post_view.creator.id ==
1132         UserService.Instance.myUserInfo.local_user_view.person.id
1133     );
1134   }
1135
1136   get isMod(): boolean {
1137     return (
1138       this.props.moderators &&
1139       isMod(
1140         this.props.moderators.map(m => m.moderator.id),
1141         this.props.post_view.creator.id
1142       )
1143     );
1144   }
1145
1146   get isAdmin(): boolean {
1147     return (
1148       this.props.admins &&
1149       isMod(
1150         this.props.admins.map(a => a.person.id),
1151         this.props.post_view.creator.id
1152       )
1153     );
1154   }
1155
1156   get canMod(): boolean {
1157     if (this.props.admins && this.props.moderators) {
1158       let adminsThenMods = this.props.admins
1159         .map(a => a.person.id)
1160         .concat(this.props.moderators.map(m => m.moderator.id));
1161
1162       return canMod(
1163         UserService.Instance.myUserInfo,
1164         adminsThenMods,
1165         this.props.post_view.creator.id
1166       );
1167     } else {
1168       return false;
1169     }
1170   }
1171
1172   get canModOnSelf(): boolean {
1173     if (this.props.admins && this.props.moderators) {
1174       let adminsThenMods = this.props.admins
1175         .map(a => a.person.id)
1176         .concat(this.props.moderators.map(m => m.moderator.id));
1177
1178       return canMod(
1179         UserService.Instance.myUserInfo,
1180         adminsThenMods,
1181         this.props.post_view.creator.id,
1182         true
1183       );
1184     } else {
1185       return false;
1186     }
1187   }
1188
1189   get canAdmin(): boolean {
1190     return (
1191       this.props.admins &&
1192       canMod(
1193         UserService.Instance.myUserInfo,
1194         this.props.admins.map(a => a.person.id),
1195         this.props.post_view.creator.id
1196       )
1197     );
1198   }
1199
1200   get amCommunityCreator(): boolean {
1201     return (
1202       this.props.moderators &&
1203       UserService.Instance.myUserInfo &&
1204       this.props.post_view.creator.id !=
1205         UserService.Instance.myUserInfo.local_user_view.person.id &&
1206       UserService.Instance.myUserInfo.local_user_view.person.id ==
1207         this.props.moderators[0].moderator.id
1208     );
1209   }
1210
1211   get amSiteCreator(): boolean {
1212     return (
1213       this.props.admins &&
1214       UserService.Instance.myUserInfo &&
1215       this.props.post_view.creator.id !=
1216         UserService.Instance.myUserInfo.local_user_view.person.id &&
1217       UserService.Instance.myUserInfo.local_user_view.person.id ==
1218         this.props.admins[0].person.id
1219     );
1220   }
1221
1222   handlePostLike(i: PostListing, event: any) {
1223     event.preventDefault();
1224     if (!UserService.Instance.myUserInfo) {
1225       this.context.router.history.push(`/login`);
1226     }
1227
1228     let new_vote = i.state.my_vote == 1 ? 0 : 1;
1229
1230     if (i.state.my_vote == 1) {
1231       i.state.score--;
1232       i.state.upvotes--;
1233     } else if (i.state.my_vote == -1) {
1234       i.state.downvotes--;
1235       i.state.upvotes++;
1236       i.state.score += 2;
1237     } else {
1238       i.state.upvotes++;
1239       i.state.score++;
1240     }
1241
1242     i.state.my_vote = new_vote;
1243
1244     let form: CreatePostLike = {
1245       post_id: i.props.post_view.post.id,
1246       score: i.state.my_vote,
1247       auth: authField(),
1248     };
1249
1250     WebSocketService.Instance.send(wsClient.likePost(form));
1251     i.setState(i.state);
1252     setupTippy();
1253   }
1254
1255   handlePostDisLike(i: PostListing, event: any) {
1256     event.preventDefault();
1257     if (!UserService.Instance.myUserInfo) {
1258       this.context.router.history.push(`/login`);
1259     }
1260
1261     let new_vote = i.state.my_vote == -1 ? 0 : -1;
1262
1263     if (i.state.my_vote == 1) {
1264       i.state.score -= 2;
1265       i.state.upvotes--;
1266       i.state.downvotes++;
1267     } else if (i.state.my_vote == -1) {
1268       i.state.downvotes--;
1269       i.state.score++;
1270     } else {
1271       i.state.downvotes++;
1272       i.state.score--;
1273     }
1274
1275     i.state.my_vote = new_vote;
1276
1277     let form: CreatePostLike = {
1278       post_id: i.props.post_view.post.id,
1279       score: i.state.my_vote,
1280       auth: authField(),
1281     };
1282
1283     WebSocketService.Instance.send(wsClient.likePost(form));
1284     i.setState(i.state);
1285     setupTippy();
1286   }
1287
1288   handleEditClick(i: PostListing) {
1289     i.state.showEdit = true;
1290     i.setState(i.state);
1291   }
1292
1293   handleEditCancel() {
1294     this.state.showEdit = false;
1295     this.setState(this.state);
1296   }
1297
1298   // The actual editing is done in the recieve for post
1299   handleEditPost() {
1300     this.state.showEdit = false;
1301     this.setState(this.state);
1302   }
1303
1304   handleBlockUserClick(i: PostListing) {
1305     let blockUserForm: BlockPerson = {
1306       person_id: i.props.post_view.creator.id,
1307       block: true,
1308       auth: authField(),
1309     };
1310     WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
1311   }
1312
1313   handleDeleteClick(i: PostListing) {
1314     let deleteForm: DeletePost = {
1315       post_id: i.props.post_view.post.id,
1316       deleted: !i.props.post_view.post.deleted,
1317       auth: authField(),
1318     };
1319     WebSocketService.Instance.send(wsClient.deletePost(deleteForm));
1320   }
1321
1322   handleSavePostClick(i: PostListing) {
1323     let saved =
1324       i.props.post_view.saved == undefined ? true : !i.props.post_view.saved;
1325     let form: SavePost = {
1326       post_id: i.props.post_view.post.id,
1327       save: saved,
1328       auth: authField(),
1329     };
1330
1331     WebSocketService.Instance.send(wsClient.savePost(form));
1332   }
1333
1334   get crossPostParams(): string {
1335     let post = this.props.post_view.post;
1336     let params = `?title=${encodeURIComponent(post.name)}`;
1337
1338     if (post.url) {
1339       params += `&url=${encodeURIComponent(post.url)}`;
1340     }
1341     if (post.body) {
1342       params += `&body=${encodeURIComponent(this.crossPostBody())}`;
1343     }
1344     return params;
1345   }
1346
1347   crossPostBody(): string {
1348     let post = this.props.post_view.post;
1349     let body = `${i18n.t("cross_posted_from")} ${
1350       post.ap_id
1351     }\n\n${post.body.replace(/^/gm, "> ")}`;
1352     return body;
1353   }
1354
1355   get showBody(): boolean {
1356     return this.props.showBody || this.state.showBody;
1357   }
1358
1359   handleModRemoveShow(i: PostListing) {
1360     i.state.showRemoveDialog = true;
1361     i.setState(i.state);
1362   }
1363
1364   handleModRemoveReasonChange(i: PostListing, event: any) {
1365     i.state.removeReason = event.target.value;
1366     i.setState(i.state);
1367   }
1368
1369   handleModRemoveDataChange(i: PostListing, event: any) {
1370     i.state.removeData = event.target.checked;
1371     i.setState(i.state);
1372   }
1373
1374   handleModRemoveSubmit(i: PostListing, event: any) {
1375     event.preventDefault();
1376     let form: RemovePost = {
1377       post_id: i.props.post_view.post.id,
1378       removed: !i.props.post_view.post.removed,
1379       reason: i.state.removeReason,
1380       auth: authField(),
1381     };
1382     WebSocketService.Instance.send(wsClient.removePost(form));
1383
1384     i.state.showRemoveDialog = false;
1385     i.setState(i.state);
1386   }
1387
1388   handleModLock(i: PostListing) {
1389     let form: LockPost = {
1390       post_id: i.props.post_view.post.id,
1391       locked: !i.props.post_view.post.locked,
1392       auth: authField(),
1393     };
1394     WebSocketService.Instance.send(wsClient.lockPost(form));
1395   }
1396
1397   handleModSticky(i: PostListing) {
1398     let form: StickyPost = {
1399       post_id: i.props.post_view.post.id,
1400       stickied: !i.props.post_view.post.stickied,
1401       auth: authField(),
1402     };
1403     WebSocketService.Instance.send(wsClient.stickyPost(form));
1404   }
1405
1406   handleModBanFromCommunityShow(i: PostListing) {
1407     i.state.showBanDialog = true;
1408     i.state.banType = BanType.Community;
1409     i.setState(i.state);
1410   }
1411
1412   handleModBanShow(i: PostListing) {
1413     i.state.showBanDialog = true;
1414     i.state.banType = BanType.Site;
1415     i.setState(i.state);
1416   }
1417
1418   handleModBanReasonChange(i: PostListing, event: any) {
1419     i.state.banReason = event.target.value;
1420     i.setState(i.state);
1421   }
1422
1423   handleModBanExpiresChange(i: PostListing, event: any) {
1424     i.state.banExpires = event.target.value;
1425     i.setState(i.state);
1426   }
1427
1428   handleModBanFromCommunitySubmit(i: PostListing) {
1429     i.state.banType = BanType.Community;
1430     i.setState(i.state);
1431     i.handleModBanBothSubmit(i);
1432   }
1433
1434   handleModBanSubmit(i: PostListing) {
1435     i.state.banType = BanType.Site;
1436     i.setState(i.state);
1437     i.handleModBanBothSubmit(i);
1438   }
1439
1440   handleModBanBothSubmit(i: PostListing, event?: any) {
1441     if (event) event.preventDefault();
1442
1443     if (i.state.banType == BanType.Community) {
1444       // If its an unban, restore all their data
1445       let ban = !i.props.post_view.creator_banned_from_community;
1446       if (ban == false) {
1447         i.state.removeData = false;
1448       }
1449       let form: BanFromCommunity = {
1450         person_id: i.props.post_view.creator.id,
1451         community_id: i.props.post_view.community.id,
1452         ban,
1453         remove_data: i.state.removeData,
1454         reason: i.state.banReason,
1455         expires: getUnixTime(i.state.banExpires),
1456         auth: authField(),
1457       };
1458       WebSocketService.Instance.send(wsClient.banFromCommunity(form));
1459     } else {
1460       // If its an unban, restore all their data
1461       let ban = !i.props.post_view.creator.banned;
1462       if (ban == false) {
1463         i.state.removeData = false;
1464       }
1465       let form: BanPerson = {
1466         person_id: i.props.post_view.creator.id,
1467         ban,
1468         remove_data: i.state.removeData,
1469         reason: i.state.banReason,
1470         expires: getUnixTime(i.state.banExpires),
1471         auth: authField(),
1472       };
1473       WebSocketService.Instance.send(wsClient.banPerson(form));
1474     }
1475
1476     i.state.showBanDialog = false;
1477     i.setState(i.state);
1478   }
1479
1480   handleAddModToCommunity(i: PostListing) {
1481     let form: AddModToCommunity = {
1482       person_id: i.props.post_view.creator.id,
1483       community_id: i.props.post_view.community.id,
1484       added: !i.isMod,
1485       auth: authField(),
1486     };
1487     WebSocketService.Instance.send(wsClient.addModToCommunity(form));
1488     i.setState(i.state);
1489   }
1490
1491   handleAddAdmin(i: PostListing) {
1492     let form: AddAdmin = {
1493       person_id: i.props.post_view.creator.id,
1494       added: !i.isAdmin,
1495       auth: authField(),
1496     };
1497     WebSocketService.Instance.send(wsClient.addAdmin(form));
1498     i.setState(i.state);
1499   }
1500
1501   handleShowConfirmTransferCommunity(i: PostListing) {
1502     i.state.showConfirmTransferCommunity = true;
1503     i.setState(i.state);
1504   }
1505
1506   handleCancelShowConfirmTransferCommunity(i: PostListing) {
1507     i.state.showConfirmTransferCommunity = false;
1508     i.setState(i.state);
1509   }
1510
1511   handleTransferCommunity(i: PostListing) {
1512     let form: TransferCommunity = {
1513       community_id: i.props.post_view.community.id,
1514       person_id: i.props.post_view.creator.id,
1515       auth: authField(),
1516     };
1517     WebSocketService.Instance.send(wsClient.transferCommunity(form));
1518     i.state.showConfirmTransferCommunity = false;
1519     i.setState(i.state);
1520   }
1521
1522   handleShowConfirmTransferSite(i: PostListing) {
1523     i.state.showConfirmTransferSite = true;
1524     i.setState(i.state);
1525   }
1526
1527   handleCancelShowConfirmTransferSite(i: PostListing) {
1528     i.state.showConfirmTransferSite = false;
1529     i.setState(i.state);
1530   }
1531
1532   handleTransferSite(i: PostListing) {
1533     let form: TransferSite = {
1534       person_id: i.props.post_view.creator.id,
1535       auth: authField(),
1536     };
1537     WebSocketService.Instance.send(wsClient.transferSite(form));
1538     i.state.showConfirmTransferSite = false;
1539     i.setState(i.state);
1540   }
1541
1542   handleImageExpandClick(i: PostListing) {
1543     i.state.imageExpanded = !i.state.imageExpanded;
1544     i.setState(i.state);
1545   }
1546
1547   handleViewSource(i: PostListing) {
1548     i.state.viewSource = !i.state.viewSource;
1549     i.setState(i.state);
1550   }
1551
1552   handleShowAdvanced(i: PostListing) {
1553     i.state.showAdvanced = !i.state.showAdvanced;
1554     i.setState(i.state);
1555     setupTippy();
1556   }
1557
1558   handleShowMoreMobile(i: PostListing) {
1559     i.state.showMoreMobile = !i.state.showMoreMobile;
1560     i.state.showAdvanced = !i.state.showAdvanced;
1561     i.setState(i.state);
1562     setupTippy();
1563   }
1564
1565   handleShowBody(i: PostListing) {
1566     i.state.showBody = !i.state.showBody;
1567     i.setState(i.state);
1568     setupTippy();
1569   }
1570
1571   get pointsTippy(): string {
1572     let points = i18n.t("number_of_points", {
1573       count: this.state.score,
1574     });
1575
1576     let upvotes = i18n.t("number_of_upvotes", {
1577       count: this.state.upvotes,
1578     });
1579
1580     let downvotes = i18n.t("number_of_downvotes", {
1581       count: this.state.downvotes,
1582     });
1583
1584     return `${points} • ${upvotes} • ${downvotes}`;
1585   }
1586 }