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