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