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