]> Untitled Git - lemmy-ui.git/blob - src/shared/components/post/post-listing.tsx
b2e1f7e014ee9d73a74bff21b11c2d25ba7283c6
[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         <a
207           href={this.getImageSrc()}
208           class="float-right text-body d-inline-block position-relative mb-2"
209           data-tippy-content={i18n.t("expand_here")}
210           onClick={linkEvent(this, this.handleImageExpandClick)}
211           aria-label={i18n.t("expand_here")}
212         >
213           {this.imgThumb(this.getImageSrc())}
214           <Icon icon="image" classes="mini-overlay" />
215         </a>
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                   <a
423                     href={this.getImageSrc()}
424                     class="btn btn-link d-inline-block"
425                     onClick={linkEvent(this, this.handleImageExpandClick)}
426                   >
427                     <PictrsImage src={this.getImageSrc()} />
428                   </a>
429                 </div>
430               </span>
431             ))}
432           {post.removed && (
433             <small className="ml-2 text-muted font-italic">
434               {i18n.t("removed")}
435             </small>
436           )}
437           {post.deleted && (
438             <small
439               className="unselectable pointer ml-2 text-muted font-italic"
440               data-tippy-content={i18n.t("deleted")}
441             >
442               <Icon icon="trash" classes="icon-inline text-danger" />
443             </small>
444           )}
445           {post.locked && (
446             <small
447               className="unselectable pointer ml-2 text-muted font-italic"
448               data-tippy-content={i18n.t("locked")}
449             >
450               <Icon icon="lock" classes="icon-inline text-danger" />
451             </small>
452           )}
453           {post.stickied && (
454             <small
455               className="unselectable pointer ml-2 text-muted font-italic"
456               data-tippy-content={i18n.t("stickied")}
457             >
458               <Icon icon="pin" classes="icon-inline text-primary" />
459             </small>
460           )}
461           {post.nsfw && (
462             <small className="ml-2 text-muted font-italic">
463               {i18n.t("nsfw")}
464             </small>
465           )}
466         </h5>
467       </div>
468     );
469   }
470
471   commentsLine(mobile = false) {
472     let post_view = this.props.post_view;
473     return (
474       <div class="d-flex justify-content-between justify-content-lg-start flex-wrap text-muted font-weight-bold mb-1">
475         <button class="btn btn-link text-muted p-0">
476           <Link
477             className="text-muted small"
478             title={i18n.t("number_of_comments", {
479               count: post_view.counts.comments,
480               formattedCount: post_view.counts.comments,
481             })}
482             to={`/post/${post_view.post.id}?scrollToComments=true`}
483           >
484             <Icon icon="message-square" classes="icon-inline mr-1" />
485             {i18n.t("number_of_comments", {
486               count: post_view.counts.comments,
487               formattedCount: numToSI(post_view.counts.comments),
488             })}
489           </Link>
490         </button>
491         {!mobile && (
492           <>
493             {this.state.downvotes !== 0 && showScores() && (
494               <button
495                 class="btn text-muted py-0 pr-0"
496                 data-tippy-content={this.pointsTippy}
497                 aria-label={i18n.t("downvote")}
498               >
499                 <small>
500                   <Icon icon="arrow-down1" classes="icon-inline mr-1" />
501                   <span>{numToSI(this.state.downvotes)}</span>
502                 </small>
503               </button>
504             )}
505             {!this.showBody && (
506               <button
507                 class="btn btn-link btn-animate text-muted py-0"
508                 onClick={linkEvent(this, this.handleSavePostClick)}
509                 data-tippy-content={
510                   post_view.saved ? i18n.t("unsave") : i18n.t("save")
511                 }
512                 aria-label={post_view.saved ? i18n.t("unsave") : i18n.t("save")}
513               >
514                 <small>
515                   <Icon
516                     icon="star"
517                     classes={`icon-inline ${post_view.saved && "text-warning"}`}
518                   />
519                 </small>
520               </button>
521             )}
522           </>
523         )}
524         {/* This is an expanding spacer for mobile */}
525         <div className="flex-grow-1"></div>
526         {mobile && (
527           <>
528             <div>
529               {showScores() ? (
530                 <button
531                   className={`btn-animate btn py-0 px-1 ${
532                     this.state.my_vote == 1 ? "text-info" : "text-muted"
533                   }`}
534                   data-tippy-content={this.pointsTippy}
535                   onClick={linkEvent(this, this.handlePostLike)}
536                   aria-label={i18n.t("upvote")}
537                 >
538                   <Icon icon="arrow-up1" classes="icon-inline small mr-2" />
539                   {numToSI(this.state.upvotes)}
540                 </button>
541               ) : (
542                 <button
543                   className={`btn-animate btn py-0 px-1 ${
544                     this.state.my_vote == 1 ? "text-info" : "text-muted"
545                   }`}
546                   onClick={linkEvent(this, this.handlePostLike)}
547                   aria-label={i18n.t("upvote")}
548                 >
549                   <Icon icon="arrow-up1" classes="icon-inline small" />
550                 </button>
551               )}
552               {this.props.enableDownvotes &&
553                 (showScores() ? (
554                   <button
555                     className={`ml-2 btn-animate btn py-0 pl-1 ${
556                       this.state.my_vote == -1 ? "text-danger" : "text-muted"
557                     }`}
558                     onClick={linkEvent(this, this.handlePostDisLike)}
559                     data-tippy-content={this.pointsTippy}
560                     aria-label={i18n.t("downvote")}
561                   >
562                     <Icon icon="arrow-down1" classes="icon-inline small mr-2" />
563                     {this.state.downvotes !== 0 && (
564                       <span>{numToSI(this.state.downvotes)}</span>
565                     )}
566                   </button>
567                 ) : (
568                   <button
569                     className={`ml-2 btn-animate btn py-0 pl-1 ${
570                       this.state.my_vote == -1 ? "text-danger" : "text-muted"
571                     }`}
572                     onClick={linkEvent(this, this.handlePostDisLike)}
573                     aria-label={i18n.t("downvote")}
574                   >
575                     <Icon icon="arrow-down1" classes="icon-inline small" />
576                   </button>
577                 ))}
578             </div>
579             <button
580               class="btn btn-link btn-animate text-muted py-0 pl-1 pr-0"
581               onClick={linkEvent(this, this.handleSavePostClick)}
582               aria-label={post_view.saved ? i18n.t("unsave") : i18n.t("save")}
583               data-tippy-content={
584                 post_view.saved ? i18n.t("unsave") : i18n.t("save")
585               }
586             >
587               <Icon
588                 icon="star"
589                 classes={`icon-inline ${post_view.saved && "text-warning"}`}
590               />
591             </button>
592
593             {!this.state.showMoreMobile && this.showBody && (
594               <button
595                 class="btn btn-link btn-animate text-muted py-0"
596                 onClick={linkEvent(this, this.handleShowMoreMobile)}
597                 aria-label={i18n.t("more")}
598                 data-tippy-content={i18n.t("more")}
599               >
600                 <Icon icon="more-vertical" classes="icon-inline" />
601               </button>
602             )}
603             {this.state.showMoreMobile && this.postActions(mobile)}
604           </>
605         )}
606       </div>
607     );
608   }
609
610   duplicatesLine() {
611     let dupes = this.props.duplicates;
612     return (
613       dupes &&
614       dupes.length > 0 && (
615         <ul class="list-inline mb-1 small text-muted">
616           <>
617             <li className="list-inline-item mr-2">
618               {i18n.t("cross_posted_to")}
619             </li>
620             {dupes.map(pv => (
621               <li className="list-inline-item mr-2">
622                 <Link to={`/post/${pv.post.id}`}>
623                   {pv.community.local
624                     ? pv.community.name
625                     : `${pv.community.name}@${hostname(pv.community.actor_id)}`}
626                 </Link>
627               </li>
628             ))}
629           </>
630         </ul>
631       )
632     );
633   }
634
635   postActions(mobile = false) {
636     let post_view = this.props.post_view;
637     return (
638       UserService.Instance.myUserInfo && (
639         <>
640           {this.showBody && (
641             <>
642               {!mobile && (
643                 <button
644                   class="btn btn-link btn-animate text-muted py-0 pl-0"
645                   onClick={linkEvent(this, this.handleSavePostClick)}
646                   data-tippy-content={
647                     post_view.saved ? i18n.t("unsave") : i18n.t("save")
648                   }
649                   aria-label={
650                     post_view.saved ? i18n.t("unsave") : i18n.t("save")
651                   }
652                 >
653                   <Icon
654                     icon="star"
655                     classes={`icon-inline ${post_view.saved && "text-warning"}`}
656                   />
657                 </button>
658               )}
659               <Link
660                 className="btn btn-link btn-animate text-muted py-0"
661                 to={`/create_post${this.crossPostParams}`}
662                 title={i18n.t("cross_post")}
663               >
664                 <Icon icon="copy" classes="icon-inline" />
665               </Link>
666               {!this.myPost && (
667                 <button
668                   class="btn btn-link btn-animate text-muted py-0"
669                   onClick={linkEvent(this, this.handleBlockUserClick)}
670                   data-tippy-content={i18n.t("block_user")}
671                   aria-label={i18n.t("block_user")}
672                 >
673                   <Icon icon="slash" classes="icon-inline" />
674                 </button>
675               )}
676             </>
677           )}
678           {this.myPost && this.showBody && (
679             <>
680               <button
681                 class="btn btn-link btn-animate text-muted py-0"
682                 onClick={linkEvent(this, this.handleEditClick)}
683                 data-tippy-content={i18n.t("edit")}
684                 aria-label={i18n.t("edit")}
685               >
686                 <Icon icon="edit" classes="icon-inline" />
687               </button>
688               <button
689                 class="btn btn-link btn-animate text-muted py-0"
690                 onClick={linkEvent(this, this.handleDeleteClick)}
691                 data-tippy-content={
692                   !post_view.post.deleted ? i18n.t("delete") : i18n.t("restore")
693                 }
694                 aria-label={
695                   !post_view.post.deleted ? i18n.t("delete") : i18n.t("restore")
696                 }
697               >
698                 <Icon
699                   icon="trash"
700                   classes={`icon-inline ${
701                     post_view.post.deleted && "text-danger"
702                   }`}
703                 />
704               </button>
705             </>
706           )}
707
708           {!this.state.showAdvanced && this.showBody ? (
709             <button
710               class="btn btn-link btn-animate text-muted py-0"
711               onClick={linkEvent(this, this.handleShowAdvanced)}
712               data-tippy-content={i18n.t("more")}
713               aria-label={i18n.t("more")}
714             >
715               <Icon icon="more-vertical" classes="icon-inline" />
716             </button>
717           ) : (
718             <>
719               {this.showBody && post_view.post.body && (
720                 <button
721                   class="btn btn-link btn-animate text-muted py-0"
722                   onClick={linkEvent(this, this.handleViewSource)}
723                   data-tippy-content={i18n.t("view_source")}
724                   aria-label={i18n.t("view_source")}
725                 >
726                   <Icon
727                     icon="file-text"
728                     classes={`icon-inline ${
729                       this.state.viewSource && "text-success"
730                     }`}
731                   />
732                 </button>
733               )}
734               {this.canModOnSelf && (
735                 <>
736                   <button
737                     class="btn btn-link btn-animate text-muted py-0"
738                     onClick={linkEvent(this, this.handleModLock)}
739                     data-tippy-content={
740                       post_view.post.locked ? i18n.t("unlock") : i18n.t("lock")
741                     }
742                     aria-label={
743                       post_view.post.locked ? i18n.t("unlock") : i18n.t("lock")
744                     }
745                   >
746                     <Icon
747                       icon="lock"
748                       classes={`icon-inline ${
749                         post_view.post.locked && "text-danger"
750                       }`}
751                     />
752                   </button>
753                   <button
754                     class="btn btn-link btn-animate text-muted py-0"
755                     onClick={linkEvent(this, this.handleModSticky)}
756                     data-tippy-content={
757                       post_view.post.stickied
758                         ? i18n.t("unsticky")
759                         : i18n.t("sticky")
760                     }
761                     aria-label={
762                       post_view.post.stickied
763                         ? i18n.t("unsticky")
764                         : i18n.t("sticky")
765                     }
766                   >
767                     <Icon
768                       icon="pin"
769                       classes={`icon-inline ${
770                         post_view.post.stickied && "text-success"
771                       }`}
772                     />
773                   </button>
774                 </>
775               )}
776               {/* Mods can ban from community, and appoint as mods to community */}
777               {(this.canMod || this.canAdmin) &&
778                 (!post_view.post.removed ? (
779                   <button
780                     class="btn btn-link btn-animate text-muted py-0"
781                     onClick={linkEvent(this, this.handleModRemoveShow)}
782                     aria-label={i18n.t("remove")}
783                   >
784                     {i18n.t("remove")}
785                   </button>
786                 ) : (
787                   <button
788                     class="btn btn-link btn-animate text-muted py-0"
789                     onClick={linkEvent(this, this.handleModRemoveSubmit)}
790                     aria-label={i18n.t("restore")}
791                   >
792                     {i18n.t("restore")}
793                   </button>
794                 ))}
795               {this.canMod && (
796                 <>
797                   {!this.isMod &&
798                     (!post_view.creator_banned_from_community ? (
799                       <button
800                         class="btn btn-link btn-animate text-muted py-0"
801                         onClick={linkEvent(
802                           this,
803                           this.handleModBanFromCommunityShow
804                         )}
805                         aria-label={i18n.t("ban")}
806                       >
807                         {i18n.t("ban")}
808                       </button>
809                     ) : (
810                       <button
811                         class="btn btn-link btn-animate text-muted py-0"
812                         onClick={linkEvent(
813                           this,
814                           this.handleModBanFromCommunitySubmit
815                         )}
816                         aria-label={i18n.t("unban")}
817                       >
818                         {i18n.t("unban")}
819                       </button>
820                     ))}
821                   {!post_view.creator_banned_from_community && (
822                     <button
823                       class="btn btn-link btn-animate text-muted py-0"
824                       onClick={linkEvent(this, this.handleAddModToCommunity)}
825                       aria-label={
826                         this.isMod
827                           ? i18n.t("remove_as_mod")
828                           : i18n.t("appoint_as_mod")
829                       }
830                     >
831                       {this.isMod
832                         ? i18n.t("remove_as_mod")
833                         : i18n.t("appoint_as_mod")}
834                     </button>
835                   )}
836                 </>
837               )}
838               {/* Community creators and admins can transfer community to another mod */}
839               {(this.amCommunityCreator || this.canAdmin) &&
840                 this.isMod &&
841                 (!this.state.showConfirmTransferCommunity ? (
842                   <button
843                     class="btn btn-link btn-animate text-muted py-0"
844                     onClick={linkEvent(
845                       this,
846                       this.handleShowConfirmTransferCommunity
847                     )}
848                     aria-label={i18n.t("transfer_community")}
849                   >
850                     {i18n.t("transfer_community")}
851                   </button>
852                 ) : (
853                   <>
854                     <button
855                       class="d-inline-block mr-1 btn btn-link btn-animate text-muted py-0"
856                       aria-label={i18n.t("are_you_sure")}
857                     >
858                       {i18n.t("are_you_sure")}
859                     </button>
860                     <button
861                       class="btn btn-link btn-animate text-muted py-0 d-inline-block mr-1"
862                       aria-label={i18n.t("yes")}
863                       onClick={linkEvent(this, this.handleTransferCommunity)}
864                     >
865                       {i18n.t("yes")}
866                     </button>
867                     <button
868                       class="btn btn-link btn-animate text-muted py-0 d-inline-block"
869                       onClick={linkEvent(
870                         this,
871                         this.handleCancelShowConfirmTransferCommunity
872                       )}
873                       aria-label={i18n.t("no")}
874                     >
875                       {i18n.t("no")}
876                     </button>
877                   </>
878                 ))}
879               {/* Admins can ban from all, and appoint other admins */}
880               {this.canAdmin && (
881                 <>
882                   {!this.isAdmin &&
883                     (!post_view.creator.banned ? (
884                       <button
885                         class="btn btn-link btn-animate text-muted py-0"
886                         onClick={linkEvent(this, this.handleModBanShow)}
887                         aria-label={i18n.t("ban_from_site")}
888                       >
889                         {i18n.t("ban_from_site")}
890                       </button>
891                     ) : (
892                       <button
893                         class="btn btn-link btn-animate text-muted py-0"
894                         onClick={linkEvent(this, this.handleModBanSubmit)}
895                         aria-label={i18n.t("unban_from_site")}
896                       >
897                         {i18n.t("unban_from_site")}
898                       </button>
899                     ))}
900                   {!post_view.creator.banned && post_view.creator.local && (
901                     <button
902                       class="btn btn-link btn-animate text-muted py-0"
903                       onClick={linkEvent(this, this.handleAddAdmin)}
904                       aria-label={
905                         this.isAdmin
906                           ? i18n.t("remove_as_admin")
907                           : i18n.t("appoint_as_admin")
908                       }
909                     >
910                       {this.isAdmin
911                         ? i18n.t("remove_as_admin")
912                         : i18n.t("appoint_as_admin")}
913                     </button>
914                   )}
915                 </>
916               )}
917               {/* Site Creator can transfer to another admin */}
918               {this.amSiteCreator &&
919                 this.isAdmin &&
920                 (!this.state.showConfirmTransferSite ? (
921                   <button
922                     class="btn btn-link btn-animate text-muted py-0"
923                     onClick={linkEvent(
924                       this,
925                       this.handleShowConfirmTransferSite
926                     )}
927                     aria-label={i18n.t("transfer_site")}
928                   >
929                     {i18n.t("transfer_site")}
930                   </button>
931                 ) : (
932                   <>
933                     <button
934                       class="btn btn-link btn-animate text-muted py-0 d-inline-block mr-1"
935                       aria-label={i18n.t("are_you_sure")}
936                     >
937                       {i18n.t("are_you_sure")}
938                     </button>
939                     <button
940                       class="btn btn-link btn-animate text-muted py-0 d-inline-block mr-1"
941                       onClick={linkEvent(this, this.handleTransferSite)}
942                       aria-label={i18n.t("yes")}
943                     >
944                       {i18n.t("yes")}
945                     </button>
946                     <button
947                       class="btn btn-link btn-animate text-muted py-0 d-inline-block"
948                       onClick={linkEvent(
949                         this,
950                         this.handleCancelShowConfirmTransferSite
951                       )}
952                       aria-label={i18n.t("no")}
953                     >
954                       {i18n.t("no")}
955                     </button>
956                   </>
957                 ))}
958             </>
959           )}
960         </>
961       )
962     );
963   }
964
965   removeAndBanDialogs() {
966     let post = this.props.post_view;
967     return (
968       <>
969         {this.state.showRemoveDialog && (
970           <form
971             class="form-inline"
972             onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
973           >
974             <label class="sr-only" htmlFor="post-listing-remove-reason">
975               {i18n.t("reason")}
976             </label>
977             <input
978               type="text"
979               id="post-listing-remove-reason"
980               class="form-control mr-2"
981               placeholder={i18n.t("reason")}
982               value={this.state.removeReason}
983               onInput={linkEvent(this, this.handleModRemoveReasonChange)}
984             />
985             <button
986               type="submit"
987               class="btn btn-secondary"
988               aria-label={i18n.t("remove_post")}
989             >
990               {i18n.t("remove_post")}
991             </button>
992           </form>
993         )}
994         {this.state.showBanDialog && (
995           <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
996             <div class="form-group row">
997               <label class="col-form-label" htmlFor="post-listing-ban-reason">
998                 {i18n.t("reason")}
999               </label>
1000               <input
1001                 type="text"
1002                 id="post-listing-ban-reason"
1003                 class="form-control mr-2"
1004                 placeholder={i18n.t("reason")}
1005                 value={this.state.banReason}
1006                 onInput={linkEvent(this, this.handleModBanReasonChange)}
1007               />
1008               <div class="form-group">
1009                 <div class="form-check">
1010                   <input
1011                     class="form-check-input"
1012                     id="mod-ban-remove-data"
1013                     type="checkbox"
1014                     checked={this.state.removeData}
1015                     onChange={linkEvent(this, this.handleModRemoveDataChange)}
1016                   />
1017                   <label
1018                     class="form-check-label"
1019                     htmlFor="mod-ban-remove-data"
1020                     title={i18n.t("remove_content_more")}
1021                   >
1022                     {i18n.t("remove_content")}
1023                   </label>
1024                 </div>
1025               </div>
1026             </div>
1027             {/* TODO hold off on expires until later */}
1028             {/* <div class="form-group row"> */}
1029             {/*   <label class="col-form-label">Expires</label> */}
1030             {/*   <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
1031             {/* </div> */}
1032             <div class="form-group row">
1033               <button
1034                 type="submit"
1035                 class="btn btn-secondary"
1036                 aria-label={i18n.t("ban")}
1037               >
1038                 {i18n.t("ban")} {post.creator.name}
1039               </button>
1040             </div>
1041           </form>
1042         )}
1043       </>
1044     );
1045   }
1046
1047   mobileThumbnail() {
1048     let post = this.props.post_view.post;
1049     return post.thumbnail_url || isImage(post.url) ? (
1050       <div class="row">
1051         <div className={`${this.state.imageExpanded ? "col-12" : "col-8"}`}>
1052           {this.postTitleLine()}
1053         </div>
1054         <div class="col-4">
1055           {/* Post body prev or thumbnail */}
1056           {!this.state.imageExpanded && this.thumbnail()}
1057         </div>
1058       </div>
1059     ) : (
1060       this.postTitleLine()
1061     );
1062   }
1063
1064   showMobilePreview() {
1065     let post = this.props.post_view.post;
1066     return (
1067       post.body &&
1068       !this.showBody && (
1069         <div
1070           className="md-div mb-1"
1071           dangerouslySetInnerHTML={{
1072             __html: md.render(previewLines(post.body)),
1073           }}
1074         />
1075       )
1076     );
1077   }
1078
1079   listing() {
1080     return (
1081       <>
1082         {/* The mobile view*/}
1083         <div class="d-block d-sm-none">
1084           <div class="row">
1085             <div class="col-12">
1086               {this.createdLine()}
1087
1088               {/* If it has a thumbnail, do a right aligned thumbnail */}
1089               {this.mobileThumbnail()}
1090
1091               {/* Show a preview of the post body */}
1092               {this.showMobilePreview()}
1093
1094               {this.commentsLine(true)}
1095               {this.duplicatesLine()}
1096               {this.removeAndBanDialogs()}
1097             </div>
1098           </div>
1099         </div>
1100
1101         {/* The larger view*/}
1102         <div class="d-none d-sm-block">
1103           <div class="row">
1104             {this.voteBar()}
1105             {!this.state.imageExpanded && (
1106               <div class="col-sm-2 pr-0">
1107                 <div class="">{this.thumbnail()}</div>
1108               </div>
1109             )}
1110             <div
1111               class={`${
1112                 this.state.imageExpanded ? "col-12" : "col-12 col-sm-9"
1113               }`}
1114             >
1115               <div class="row">
1116                 <div className="col-12">
1117                   {this.postTitleLine()}
1118                   {this.createdLine()}
1119                   {this.commentsLine()}
1120                   {this.duplicatesLine()}
1121                   {this.postActions()}
1122                   {this.removeAndBanDialogs()}
1123                 </div>
1124               </div>
1125             </div>
1126           </div>
1127         </div>
1128       </>
1129     );
1130   }
1131
1132   private get myPost(): boolean {
1133     return (
1134       UserService.Instance.myUserInfo &&
1135       this.props.post_view.creator.id ==
1136         UserService.Instance.myUserInfo.local_user_view.person.id
1137     );
1138   }
1139
1140   get isMod(): boolean {
1141     return (
1142       this.props.moderators &&
1143       isMod(
1144         this.props.moderators.map(m => m.moderator.id),
1145         this.props.post_view.creator.id
1146       )
1147     );
1148   }
1149
1150   get isAdmin(): boolean {
1151     return (
1152       this.props.admins &&
1153       isMod(
1154         this.props.admins.map(a => a.person.id),
1155         this.props.post_view.creator.id
1156       )
1157     );
1158   }
1159
1160   get canMod(): boolean {
1161     if (this.props.admins && this.props.moderators) {
1162       let adminsThenMods = this.props.admins
1163         .map(a => a.person.id)
1164         .concat(this.props.moderators.map(m => m.moderator.id));
1165
1166       return canMod(
1167         UserService.Instance.myUserInfo,
1168         adminsThenMods,
1169         this.props.post_view.creator.id
1170       );
1171     } else {
1172       return false;
1173     }
1174   }
1175
1176   get canModOnSelf(): boolean {
1177     if (this.props.admins && this.props.moderators) {
1178       let adminsThenMods = this.props.admins
1179         .map(a => a.person.id)
1180         .concat(this.props.moderators.map(m => m.moderator.id));
1181
1182       return canMod(
1183         UserService.Instance.myUserInfo,
1184         adminsThenMods,
1185         this.props.post_view.creator.id,
1186         true
1187       );
1188     } else {
1189       return false;
1190     }
1191   }
1192
1193   get canAdmin(): boolean {
1194     return (
1195       this.props.admins &&
1196       canMod(
1197         UserService.Instance.myUserInfo,
1198         this.props.admins.map(a => a.person.id),
1199         this.props.post_view.creator.id
1200       )
1201     );
1202   }
1203
1204   get amCommunityCreator(): boolean {
1205     return (
1206       this.props.moderators &&
1207       UserService.Instance.myUserInfo &&
1208       this.props.post_view.creator.id !=
1209         UserService.Instance.myUserInfo.local_user_view.person.id &&
1210       UserService.Instance.myUserInfo.local_user_view.person.id ==
1211         this.props.moderators[0].moderator.id
1212     );
1213   }
1214
1215   get amSiteCreator(): boolean {
1216     return (
1217       this.props.admins &&
1218       UserService.Instance.myUserInfo &&
1219       this.props.post_view.creator.id !=
1220         UserService.Instance.myUserInfo.local_user_view.person.id &&
1221       UserService.Instance.myUserInfo.local_user_view.person.id ==
1222         this.props.admins[0].person.id
1223     );
1224   }
1225
1226   handlePostLike(i: PostListing, event: any) {
1227     event.preventDefault();
1228     if (!UserService.Instance.myUserInfo) {
1229       this.context.router.history.push(`/login`);
1230     }
1231
1232     let new_vote = i.state.my_vote == 1 ? 0 : 1;
1233
1234     if (i.state.my_vote == 1) {
1235       i.state.score--;
1236       i.state.upvotes--;
1237     } else if (i.state.my_vote == -1) {
1238       i.state.downvotes--;
1239       i.state.upvotes++;
1240       i.state.score += 2;
1241     } else {
1242       i.state.upvotes++;
1243       i.state.score++;
1244     }
1245
1246     i.state.my_vote = new_vote;
1247
1248     let form: CreatePostLike = {
1249       post_id: i.props.post_view.post.id,
1250       score: i.state.my_vote,
1251       auth: authField(),
1252     };
1253
1254     WebSocketService.Instance.send(wsClient.likePost(form));
1255     i.setState(i.state);
1256     setupTippy();
1257   }
1258
1259   handlePostDisLike(i: PostListing, event: any) {
1260     event.preventDefault();
1261     if (!UserService.Instance.myUserInfo) {
1262       this.context.router.history.push(`/login`);
1263     }
1264
1265     let new_vote = i.state.my_vote == -1 ? 0 : -1;
1266
1267     if (i.state.my_vote == 1) {
1268       i.state.score -= 2;
1269       i.state.upvotes--;
1270       i.state.downvotes++;
1271     } else if (i.state.my_vote == -1) {
1272       i.state.downvotes--;
1273       i.state.score++;
1274     } else {
1275       i.state.downvotes++;
1276       i.state.score--;
1277     }
1278
1279     i.state.my_vote = new_vote;
1280
1281     let form: CreatePostLike = {
1282       post_id: i.props.post_view.post.id,
1283       score: i.state.my_vote,
1284       auth: authField(),
1285     };
1286
1287     WebSocketService.Instance.send(wsClient.likePost(form));
1288     i.setState(i.state);
1289     setupTippy();
1290   }
1291
1292   handleEditClick(i: PostListing) {
1293     i.state.showEdit = true;
1294     i.setState(i.state);
1295   }
1296
1297   handleEditCancel() {
1298     this.state.showEdit = false;
1299     this.setState(this.state);
1300   }
1301
1302   // The actual editing is done in the recieve for post
1303   handleEditPost() {
1304     this.state.showEdit = false;
1305     this.setState(this.state);
1306   }
1307
1308   handleBlockUserClick(i: PostListing) {
1309     let blockUserForm: BlockPerson = {
1310       person_id: i.props.post_view.creator.id,
1311       block: true,
1312       auth: authField(),
1313     };
1314     WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
1315   }
1316
1317   handleDeleteClick(i: PostListing) {
1318     let deleteForm: DeletePost = {
1319       post_id: i.props.post_view.post.id,
1320       deleted: !i.props.post_view.post.deleted,
1321       auth: authField(),
1322     };
1323     WebSocketService.Instance.send(wsClient.deletePost(deleteForm));
1324   }
1325
1326   handleSavePostClick(i: PostListing) {
1327     let saved =
1328       i.props.post_view.saved == undefined ? true : !i.props.post_view.saved;
1329     let form: SavePost = {
1330       post_id: i.props.post_view.post.id,
1331       save: saved,
1332       auth: authField(),
1333     };
1334
1335     WebSocketService.Instance.send(wsClient.savePost(form));
1336   }
1337
1338   get crossPostParams(): string {
1339     let post = this.props.post_view.post;
1340     let params = `?title=${encodeURIComponent(post.name)}`;
1341
1342     if (post.url) {
1343       params += `&url=${encodeURIComponent(post.url)}`;
1344     }
1345     if (post.body) {
1346       params += `&body=${encodeURIComponent(this.crossPostBody())}`;
1347     }
1348     return params;
1349   }
1350
1351   crossPostBody(): string {
1352     let post = this.props.post_view.post;
1353     let body = `${i18n.t("cross_posted_from")} ${
1354       post.ap_id
1355     }\n\n${post.body.replace(/^/gm, "> ")}`;
1356     return body;
1357   }
1358
1359   get showBody(): boolean {
1360     return this.props.showBody || this.state.showBody;
1361   }
1362
1363   handleModRemoveShow(i: PostListing) {
1364     i.state.showRemoveDialog = true;
1365     i.setState(i.state);
1366   }
1367
1368   handleModRemoveReasonChange(i: PostListing, event: any) {
1369     i.state.removeReason = event.target.value;
1370     i.setState(i.state);
1371   }
1372
1373   handleModRemoveDataChange(i: PostListing, event: any) {
1374     i.state.removeData = event.target.checked;
1375     i.setState(i.state);
1376   }
1377
1378   handleModRemoveSubmit(i: PostListing, event: any) {
1379     event.preventDefault();
1380     let form: RemovePost = {
1381       post_id: i.props.post_view.post.id,
1382       removed: !i.props.post_view.post.removed,
1383       reason: i.state.removeReason,
1384       auth: authField(),
1385     };
1386     WebSocketService.Instance.send(wsClient.removePost(form));
1387
1388     i.state.showRemoveDialog = false;
1389     i.setState(i.state);
1390   }
1391
1392   handleModLock(i: PostListing) {
1393     let form: LockPost = {
1394       post_id: i.props.post_view.post.id,
1395       locked: !i.props.post_view.post.locked,
1396       auth: authField(),
1397     };
1398     WebSocketService.Instance.send(wsClient.lockPost(form));
1399   }
1400
1401   handleModSticky(i: PostListing) {
1402     let form: StickyPost = {
1403       post_id: i.props.post_view.post.id,
1404       stickied: !i.props.post_view.post.stickied,
1405       auth: authField(),
1406     };
1407     WebSocketService.Instance.send(wsClient.stickyPost(form));
1408   }
1409
1410   handleModBanFromCommunityShow(i: PostListing) {
1411     i.state.showBanDialog = true;
1412     i.state.banType = BanType.Community;
1413     i.setState(i.state);
1414   }
1415
1416   handleModBanShow(i: PostListing) {
1417     i.state.showBanDialog = true;
1418     i.state.banType = BanType.Site;
1419     i.setState(i.state);
1420   }
1421
1422   handleModBanReasonChange(i: PostListing, event: any) {
1423     i.state.banReason = event.target.value;
1424     i.setState(i.state);
1425   }
1426
1427   handleModBanExpiresChange(i: PostListing, event: any) {
1428     i.state.banExpires = event.target.value;
1429     i.setState(i.state);
1430   }
1431
1432   handleModBanFromCommunitySubmit(i: PostListing) {
1433     i.state.banType = BanType.Community;
1434     i.setState(i.state);
1435     i.handleModBanBothSubmit(i);
1436   }
1437
1438   handleModBanSubmit(i: PostListing) {
1439     i.state.banType = BanType.Site;
1440     i.setState(i.state);
1441     i.handleModBanBothSubmit(i);
1442   }
1443
1444   handleModBanBothSubmit(i: PostListing, event?: any) {
1445     if (event) event.preventDefault();
1446
1447     if (i.state.banType == BanType.Community) {
1448       // If its an unban, restore all their data
1449       let ban = !i.props.post_view.creator_banned_from_community;
1450       if (ban == false) {
1451         i.state.removeData = false;
1452       }
1453       let form: BanFromCommunity = {
1454         person_id: i.props.post_view.creator.id,
1455         community_id: i.props.post_view.community.id,
1456         ban,
1457         remove_data: i.state.removeData,
1458         reason: i.state.banReason,
1459         expires: getUnixTime(i.state.banExpires),
1460         auth: authField(),
1461       };
1462       WebSocketService.Instance.send(wsClient.banFromCommunity(form));
1463     } else {
1464       // If its an unban, restore all their data
1465       let ban = !i.props.post_view.creator.banned;
1466       if (ban == false) {
1467         i.state.removeData = false;
1468       }
1469       let form: BanPerson = {
1470         person_id: i.props.post_view.creator.id,
1471         ban,
1472         remove_data: i.state.removeData,
1473         reason: i.state.banReason,
1474         expires: getUnixTime(i.state.banExpires),
1475         auth: authField(),
1476       };
1477       WebSocketService.Instance.send(wsClient.banPerson(form));
1478     }
1479
1480     i.state.showBanDialog = false;
1481     i.setState(i.state);
1482   }
1483
1484   handleAddModToCommunity(i: PostListing) {
1485     let form: AddModToCommunity = {
1486       person_id: i.props.post_view.creator.id,
1487       community_id: i.props.post_view.community.id,
1488       added: !i.isMod,
1489       auth: authField(),
1490     };
1491     WebSocketService.Instance.send(wsClient.addModToCommunity(form));
1492     i.setState(i.state);
1493   }
1494
1495   handleAddAdmin(i: PostListing) {
1496     let form: AddAdmin = {
1497       person_id: i.props.post_view.creator.id,
1498       added: !i.isAdmin,
1499       auth: authField(),
1500     };
1501     WebSocketService.Instance.send(wsClient.addAdmin(form));
1502     i.setState(i.state);
1503   }
1504
1505   handleShowConfirmTransferCommunity(i: PostListing) {
1506     i.state.showConfirmTransferCommunity = true;
1507     i.setState(i.state);
1508   }
1509
1510   handleCancelShowConfirmTransferCommunity(i: PostListing) {
1511     i.state.showConfirmTransferCommunity = false;
1512     i.setState(i.state);
1513   }
1514
1515   handleTransferCommunity(i: PostListing) {
1516     let form: TransferCommunity = {
1517       community_id: i.props.post_view.community.id,
1518       person_id: i.props.post_view.creator.id,
1519       auth: authField(),
1520     };
1521     WebSocketService.Instance.send(wsClient.transferCommunity(form));
1522     i.state.showConfirmTransferCommunity = false;
1523     i.setState(i.state);
1524   }
1525
1526   handleShowConfirmTransferSite(i: PostListing) {
1527     i.state.showConfirmTransferSite = true;
1528     i.setState(i.state);
1529   }
1530
1531   handleCancelShowConfirmTransferSite(i: PostListing) {
1532     i.state.showConfirmTransferSite = false;
1533     i.setState(i.state);
1534   }
1535
1536   handleTransferSite(i: PostListing) {
1537     let form: TransferSite = {
1538       person_id: i.props.post_view.creator.id,
1539       auth: authField(),
1540     };
1541     WebSocketService.Instance.send(wsClient.transferSite(form));
1542     i.state.showConfirmTransferSite = false;
1543     i.setState(i.state);
1544   }
1545
1546   handleImageExpandClick(i: PostListing, event: any) {
1547     event.preventDefault();
1548     i.state.imageExpanded = !i.state.imageExpanded;
1549     i.setState(i.state);
1550   }
1551
1552   handleViewSource(i: PostListing) {
1553     i.state.viewSource = !i.state.viewSource;
1554     i.setState(i.state);
1555   }
1556
1557   handleShowAdvanced(i: PostListing) {
1558     i.state.showAdvanced = !i.state.showAdvanced;
1559     i.setState(i.state);
1560     setupTippy();
1561   }
1562
1563   handleShowMoreMobile(i: PostListing) {
1564     i.state.showMoreMobile = !i.state.showMoreMobile;
1565     i.state.showAdvanced = !i.state.showAdvanced;
1566     i.setState(i.state);
1567     setupTippy();
1568   }
1569
1570   handleShowBody(i: PostListing) {
1571     i.state.showBody = !i.state.showBody;
1572     i.setState(i.state);
1573     setupTippy();
1574   }
1575
1576   get pointsTippy(): string {
1577     let points = i18n.t("number_of_points", {
1578       count: this.state.score,
1579       formattedCount: this.state.score,
1580     });
1581
1582     let upvotes = i18n.t("number_of_upvotes", {
1583       count: this.state.upvotes,
1584       formattedCount: this.state.upvotes,
1585     });
1586
1587     let downvotes = i18n.t("number_of_downvotes", {
1588       count: this.state.downvotes,
1589       formattedCount: this.state.downvotes,
1590     });
1591
1592     return `${points} • ${upvotes} • ${downvotes}`;
1593   }
1594 }