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